diff --git a/CLAUDE.md b/CLAUDE.md index e4b036d..00fa9f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,17 +20,60 @@ GitHub Issues #1〜#5に各Stepの仕様が定義されている。 2. **Green** — テストが通る最小限の実装を書く 3. **Refactor** — コードを整理する(テストが通ることを確認しながら) -### テストの書き方(BDDスタイル) +### テストの書き方(Cucumber BDDスタイル) + +テストは2層構成: +- **BDD Feature(画面・コンポーネント)**: jest-cucumber で Gherkin(.feature)+ ステップ定義 +- **Unit Test(ロジック)**: 従来の describe/it 形式で hooks / domain / repository をテスト + +#### ファイル配置 + +``` +__tests__/ + / + screens/ # BDD Feature(画面・コンポーネント表示テスト) + Screen.feature # Gherkin仕様 + Screen.steps.tsx # ステップ定義 + domain/ # Unit Test(ドメインロジック) + hooks/ # Unit Test(カスタムフック) + repository/ # Unit Test(API呼び出し) +``` -- `describe` で機能・コンポーネント名を記述 -- `it` で振る舞いを日本語で記述 -- テストは `__tests__/` ディレクトリに配置 - **`app/` ディレクトリにテストを置かないこと**(Expo Routerがルートとして認識するため) +#### .feature ファイルの書き方 + +- キーワードは英語(Feature/Scenario/Given/When/Then/And)、説明文は日本語で記述 +- 同じロジックに複数の入力パターンがある場合は `Scenario Outline:` + `Examples:` テーブルを使う + +```gherkin +Feature: ポケモンリストアイテムの変換 + + Scenario Outline: URLからポケモンIDを抽出する + Given PokeAPIのURL "" が与えられている + When URLからIDを抽出する + Then IDは である + + Examples: + | url | id | + | https://pokeapi.co/api/v2/pokemon/25/ | 25 | +``` + +#### ステップ定義の書き方 + +- `loadFeature()` で .feature を読み込み、`defineFeature()` でステップを定義 +- インポートは `@/src/` 経由(バレルファイル経由のルール維持) + ```typescript -describe('PokemonCard', () => { - it('ポケモンの名前が表示される', () => { ... }); - it('タイプバッジが色分けされて表示される', () => { ... }); +import { defineFeature, loadFeature } from "jest-cucumber"; +import { extractPokemonId } from "@/src/home"; + +const feature = loadFeature("__tests__/home/features/pokemonListItem.feature"); + +defineFeature(feature, (test) => { + test("URLからポケモンIDを抽出する", ({ given, when, then }) => { + // given/when/then でステップを実装 + }); }); ``` diff --git a/__tests__/detail/components/PokemonAbilities.test.tsx b/__tests__/detail/components/PokemonAbilities.test.tsx deleted file mode 100644 index c67ab66..0000000 --- a/__tests__/detail/components/PokemonAbilities.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; -import type { PokemonAbility } from "@/src/shared"; - -const mockAbilities: PokemonAbility[] = [ - { name: "overgrow", isHidden: false }, - { name: "chlorophyll", isHidden: true }, -]; - -describe("PokemonAbilities", () => { - it("セクションタイトルが表示される", () => { - render(); - expect(screen.getByText("detail.abilities")).toBeTruthy(); - }); - - it("とくせい名がキャピタライズされて表示される", () => { - render(); - expect(screen.getByText("Overgrow")).toBeTruthy(); - }); - - it("隠れとくせいにラベルが付与される", () => { - render(); - expect(screen.getByText("Chlorophyll detail.hiddenAbility")).toBeTruthy(); - }); - - it("複数のとくせいが全て表示される", () => { - render(); - expect(screen.getByText("Overgrow")).toBeTruthy(); - expect(screen.getByText("Chlorophyll detail.hiddenAbility")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/components/PokemonDetail.test.tsx b/__tests__/detail/components/PokemonDetail.test.tsx deleted file mode 100644 index db3f9c2..0000000 --- a/__tests__/detail/components/PokemonDetail.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; -import type { Pokemon } from "@/src/shared"; - -const mockPokemon: Pokemon = { - id: 25, - name: "pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const multiTypePokemon: Pokemon = { - id: 6, - name: "charizard", - types: ["fire", "flying"], - stats: [ - { name: "hp", baseStat: 78 }, - { name: "attack", baseStat: 84 }, - { name: "defense", baseStat: 78 }, - { name: "special-attack", baseStat: 109 }, - { name: "special-defense", baseStat: 85 }, - { name: "speed", baseStat: 100 }, - ], - height: 17, - weight: 905, - abilities: [ - { name: "blaze", isHidden: false }, - { name: "solar-power", isHidden: true }, - ], -}; - -describe("PokemonDetail", () => { - it("ローカライズ名が渡された場合に表示される", () => { - render(); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ローカライズ名がnullの場合はAPI名が表示される", () => { - render(); - expect(screen.getByText("pikachu")).toBeTruthy(); - }); - - it("ポケモンのIDが3桁ゼロ埋めで表示される", () => { - render(); - expect(screen.getByText("#025")).toBeTruthy(); - }); - - it("ポケモンの画像が表示される", () => { - render(); - const image = screen.getByTestId("pokemon-detail-image"); - expect(image.props.source.uri).toContain("25.png"); - }); - - it("タイプバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.electric")).toBeTruthy(); - }); - - it("複数タイプが翻訳されて全て表示される", () => { - render(); - expect(screen.getByText("types.fire")).toBeTruthy(); - expect(screen.getByText("types.flying")).toBeTruthy(); - }); - - it("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", () => { - const onToggleFavorite = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - - it("isFavoriteが未指定の場合、お気に入りボタンが表示されない", () => { - render(); - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); - - it("フレーバーテキストが渡された場合に表示される", () => { - render(); - expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); - }); - - it("フレーバーテキストが未指定の場合は表示されない", () => { - render(); - expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); - }); -}); diff --git a/__tests__/detail/components/PokemonFlavorText.test.tsx b/__tests__/detail/components/PokemonFlavorText.test.tsx deleted file mode 100644 index e4f4005..0000000 --- a/__tests__/detail/components/PokemonFlavorText.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; - -describe("PokemonFlavorText", () => { - it("フレーバーテキストが表示される", () => { - render(); - expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); - }); - - it("ローディング中にインジケータが表示される", () => { - render(); - expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); - }); - - it("テキストがnullでローディングでない場合は何も表示されない", () => { - const { toJSON } = render(); - expect(toJSON()).toBeNull(); - }); -}); diff --git a/__tests__/detail/components/PokemonPhysicalInfo.test.tsx b/__tests__/detail/components/PokemonPhysicalInfo.test.tsx deleted file mode 100644 index 23ffcbe..0000000 --- a/__tests__/detail/components/PokemonPhysicalInfo.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; - -describe("PokemonPhysicalInfo", () => { - it("身長の値が表示される", () => { - render(); - expect(screen.getByText("0.7detail.heightUnit")).toBeTruthy(); - }); - - it("体重の値が表示される", () => { - render(); - expect(screen.getByText("6.9detail.weightUnit")).toBeTruthy(); - }); - - it("身長ラベルが表示される", () => { - render(); - expect(screen.getByText("detail.height")).toBeTruthy(); - }); - - it("体重ラベルが表示される", () => { - render(); - expect(screen.getByText("detail.weight")).toBeTruthy(); - }); - - it("整数の身長が正しくフォーマットされる", () => { - render(); - expect(screen.getByText("2.0detail.heightUnit")).toBeTruthy(); - expect(screen.getByText("100.0detail.weightUnit")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/components/PokemonStats.test.tsx b/__tests__/detail/components/PokemonStats.test.tsx deleted file mode 100644 index 3ab587c..0000000 --- a/__tests__/detail/components/PokemonStats.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { PokemonStats } from "@/src/detail/components/PokemonStats"; -import type { PokemonStat } from "@/src/shared"; - -const mockStats: PokemonStat[] = [ - { name: "hp", baseStat: 45 }, - { name: "attack", baseStat: 49 }, - { name: "defense", baseStat: 49 }, - { name: "special-attack", baseStat: 65 }, - { name: "special-defense", baseStat: 65 }, - { name: "speed", baseStat: 45 }, -]; - -describe("PokemonStats", () => { - it("セクションタイトルが表示される", () => { - render(); - expect(screen.getByText("detail.baseStats")).toBeTruthy(); - }); - - it("6つのステータスバーが表示される", () => { - render(); - expect(screen.getByText("detail.stats.hp")).toBeTruthy(); - expect(screen.getByText("detail.stats.attack")).toBeTruthy(); - expect(screen.getByText("detail.stats.defense")).toBeTruthy(); - expect(screen.getByText("detail.stats.special-attack")).toBeTruthy(); - expect(screen.getByText("detail.stats.special-defense")).toBeTruthy(); - expect(screen.getByText("detail.stats.speed")).toBeTruthy(); - }); - - it("各ステータスの値が正しく表示される", () => { - render(); - expect(screen.getAllByText("45")).toHaveLength(2); - expect(screen.getAllByText("49")).toHaveLength(2); - expect(screen.getAllByText("65")).toHaveLength(2); - }); -}); diff --git a/__tests__/detail/components/StatBar.test.tsx b/__tests__/detail/components/StatBar.test.tsx deleted file mode 100644 index ca168fc..0000000 --- a/__tests__/detail/components/StatBar.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { StatBar } from "@/src/detail/components/StatBar"; - -describe("StatBar", () => { - it("ステータス名が表示される", () => { - render(); - expect(screen.getByText("HP")).toBeTruthy(); - }); - - it("ステータス値が表示される", () => { - render(); - expect(screen.getByText("45")).toBeTruthy(); - }); - - it("バーの幅がステータス値に比例する", () => { - render(); - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width: "50%" }), - ]) - ); - }); - - it("maxValueのデフォルトは180", () => { - render(); - const bar = screen.getByTestId("stat-bar-fill"); - expect(bar.props.style).toEqual( - expect.arrayContaining([ - expect.objectContaining({ width: "50%" }), - ]) - ); - }); -}); diff --git a/__tests__/detail/hooks/usePokemonDetail.test.ts b/__tests__/detail/hooks/usePokemonDetail.test.ts index bece55d..d70f1f0 100644 --- a/__tests__/detail/hooks/usePokemonDetail.test.ts +++ b/__tests__/detail/hooks/usePokemonDetail.test.ts @@ -1,13 +1,13 @@ import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; import type { Pokemon } from "@/src/shared"; jest.mock("@/src/detail/repository/pokemonDetailApi"); -const mockFetch = fetchPokemonDetail as jest.MockedFunction; +const mockFetchDetail = fetchPokemonDetail as jest.MockedFunction; -const mockPokemon: Pokemon = { +const mockScreenPokemon: Pokemon = { id: 25, name: "Pikachu", types: ["electric"], @@ -29,88 +29,81 @@ const mockPokemon: Pokemon = { describe("usePokemonDetail", () => { beforeEach(() => { - mockFetch.mockReset(); + mockFetchDetail.mockReset(); }); it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); + mockFetchDetail.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => usePokemonDetail(25)); - expect(result.current.isLoading).toBe(true); expect(result.current.pokemon).toBeNull(); }); it("データ取得後にポケモン詳細が設定される", async () => { - mockFetch.mockResolvedValueOnce(mockPokemon); + mockFetchDetail.mockResolvedValueOnce(mockScreenPokemon); const { result } = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - - expect(result.current.pokemon).toEqual(mockPokemon); - expect(mockFetch).toHaveBeenCalledWith(25); + expect(result.current.isLoading).toBe(false); + expect(result.current.pokemon).toEqual(mockScreenPokemon); + expect(mockFetchDetail).toHaveBeenCalledWith(25); }); it("エラー時にerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); + mockFetchDetail.mockRejectedValueOnce(new Error("Not found")); const { result } = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe("Not found"); expect(result.current.pokemon).toBeNull(); }); it("Error以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce("string error"); + mockFetchDetail.mockRejectedValueOnce("string error"); const { result } = renderHook(() => usePokemonDetail(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe("Unknown error"); }); it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: Pokemon) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + let resolve: (value: Pokemon) => void; + mockFetchDetail.mockReturnValue(new Promise((r) => { resolve = r; })); const { result, unmount } = renderHook(() => usePokemonDetail(25)); - expect(result.current.isLoading).toBe(true); unmount(); - - await act(async () => { resolve(mockPokemon); }); - + await act(async () => { resolve!(mockScreenPokemon); }); expect(result.current.pokemon).toBeNull(); expect(result.current.isLoading).toBe(true); }); it("IDが変わると再取得する", async () => { - mockFetch.mockResolvedValueOnce(mockPokemon); + mockFetchDetail.mockResolvedValueOnce(mockScreenPokemon); const { result, rerender } = renderHook( (props: { id: number }) => usePokemonDetail(props.id), { initialProps: { id: 25 } } ); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); const newPokemon: Pokemon = { - ...mockPokemon, + ...mockScreenPokemon, id: 1, name: "Bulbasaur", types: ["grass", "poison"], }; - mockFetch.mockResolvedValueOnce(newPokemon); + mockFetchDetail.mockResolvedValueOnce(newPokemon); rerender({ id: 1 }); await waitFor(() => { expect(result.current.pokemon).toEqual(newPokemon); }); + expect(result.current.pokemon?.id).toBe(1); }); }); diff --git a/__tests__/detail/hooks/usePokemonFlavorText.test.ts b/__tests__/detail/hooks/usePokemonFlavorText.test.ts index 0998514..0d096f6 100644 --- a/__tests__/detail/hooks/usePokemonFlavorText.test.ts +++ b/__tests__/detail/hooks/usePokemonFlavorText.test.ts @@ -1,56 +1,50 @@ import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; import { fetchPokemonFlavorText } from "@/src/detail/repository/pokemonSpeciesApi"; +import { usePokemonFlavorText } from "@/src/detail/hooks/usePokemonFlavorText"; jest.mock("@/src/detail/repository/pokemonSpeciesApi"); -const mockFetch = fetchPokemonFlavorText as jest.MockedFunction; +const mockFetchFlavorText = fetchPokemonFlavorText as jest.MockedFunction; describe("usePokemonFlavorText", () => { beforeEach(() => { - mockFetch.mockReset(); + mockFetchFlavorText.mockReset(); }); it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); + mockFetchFlavorText.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => usePokemonFlavorText(25)); - expect(result.current.isLoading).toBe(true); expect(result.current.flavorText).toBeNull(); }); it("データ取得後にフレーバーテキストが設定される", async () => { - mockFetch.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); + mockFetchFlavorText.mockResolvedValueOnce("でんきを ためこむ せいしつ。"); const { result } = renderHook(() => usePokemonFlavorText(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); }); it("エラー時はflavorTextがnullのままになる", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); + mockFetchFlavorText.mockRejectedValueOnce(new Error("Not found")); const { result } = renderHook(() => usePokemonFlavorText(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.flavorText).toBeNull(); }); it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: string | null) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + let resolve: (value: string | null) => void; + mockFetchFlavorText.mockReturnValue(new Promise((r) => { resolve = r; })); const { result, unmount } = renderHook(() => usePokemonFlavorText(25)); - expect(result.current.isLoading).toBe(true); unmount(); - - await act(async () => { resolve("テスト"); }); - + await act(async () => { resolve!("テスト"); }); expect(result.current.flavorText).toBeNull(); expect(result.current.isLoading).toBe(true); }); diff --git a/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts b/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts index 6c94a4c..03dd475 100644 --- a/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts +++ b/__tests__/detail/hooks/usePokemonSpeciesInfo.test.ts @@ -1,63 +1,57 @@ import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; import { fetchPokemonSpeciesInfo } from "@/src/detail/repository/pokemonSpeciesApi"; +import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; jest.mock("@/src/detail/repository/pokemonSpeciesApi"); -const mockFetch = fetchPokemonSpeciesInfo as jest.MockedFunction; +const mockFetchSpeciesInfo = fetchPokemonSpeciesInfo as jest.MockedFunction; describe("usePokemonSpeciesInfo", () => { beforeEach(() => { - mockFetch.mockReset(); + mockFetchSpeciesInfo.mockReset(); }); it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); + mockFetchSpeciesInfo.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - expect(result.current.isLoading).toBe(true); expect(result.current.localizedName).toBeNull(); expect(result.current.flavorText).toBeNull(); }); it("ローカライズされたポケモン名とフレーバーテキストを返す", async () => { - mockFetch.mockResolvedValueOnce({ + mockFetchSpeciesInfo.mockResolvedValueOnce({ localizedName: "ピカチュウ", flavorText: "でんきを ためこむ せいしつ。", }); const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.localizedName).toBe("ピカチュウ"); expect(result.current.flavorText).toBe("でんきを ためこむ せいしつ。"); - expect(mockFetch).toHaveBeenCalledWith(25, "ja"); + expect(mockFetchSpeciesInfo).toHaveBeenCalledWith(25, "ja"); }); it("エラー時にnullを返す", async () => { - mockFetch.mockRejectedValueOnce(new Error("Not found")); + mockFetchSpeciesInfo.mockRejectedValueOnce(new Error("Not found")); const { result } = renderHook(() => usePokemonSpeciesInfo(25)); - await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - + expect(result.current.isLoading).toBe(false); expect(result.current.localizedName).toBeNull(); expect(result.current.flavorText).toBeNull(); }); it("アンマウント後にデータ取得が完了しても状態が更新されない", async () => { - let resolve!: (value: { localizedName: string | null; flavorText: string | null }) => void; - mockFetch.mockReturnValue(new Promise((r) => { resolve = r; })); + let resolve: (value: { localizedName: string | null; flavorText: string | null }) => void; + mockFetchSpeciesInfo.mockReturnValue(new Promise((r) => { resolve = r; })); const { result, unmount } = renderHook(() => usePokemonSpeciesInfo(25)); - expect(result.current.isLoading).toBe(true); unmount(); - - await act(async () => { resolve({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); - + await act(async () => { resolve!({ localizedName: "ピカチュウ", flavorText: "テスト" }); }); expect(result.current.localizedName).toBeNull(); expect(result.current.isLoading).toBe(true); }); diff --git a/__tests__/detail/repository/pokemonDetailApi.test.ts b/__tests__/detail/repository/pokemonDetailApi.test.ts index 4a9f310..88f88e2 100644 --- a/__tests__/detail/repository/pokemonDetailApi.test.ts +++ b/__tests__/detail/repository/pokemonDetailApi.test.ts @@ -1,6 +1,7 @@ import { fetchPokemonDetail } from "@/src/detail/repository/pokemonDetailApi"; +import type { Pokemon } from "@/src/shared"; -const mockApiResponse = { +const mockDetailApiResponse = { id: 25, name: "pikachu", types: [ @@ -24,36 +25,26 @@ const mockApiResponse = { const originalFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); +describe("pokemonDetailApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); -describe("fetchPokemonDetail", () => { it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - await fetchPokemonDetail(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon/25" - ); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon/25"); }); it("レスポンスからステータスを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - const result = await fetchPokemonDetail(25); - expect(result.stats).toEqual([ { name: "hp", baseStat: 35 }, { name: "attack", baseStat: 55 }, @@ -65,25 +56,21 @@ describe("fetchPokemonDetail", () => { }); it("身長と体重を正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - const result = await fetchPokemonDetail(25); - expect(result.height).toBe(4); expect(result.weight).toBe(60); }); it("とくせいを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - const result = await fetchPokemonDetail(25); - expect(result.abilities).toEqual([ { name: "static", isHidden: false }, { name: "lightning-rod", isHidden: true }, @@ -91,35 +78,28 @@ describe("fetchPokemonDetail", () => { }); it("名前がキャピタライズされる", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - const result = await fetchPokemonDetail(25); - expect(result.name).toBe("Pikachu"); }); it("タイプが正しく変換される", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockDetailApiResponse), }); - const result = await fetchPokemonDetail(25); - expect(result.types).toEqual(["electric"]); }); it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: false, status: 404, }); - - await expect(fetchPokemonDetail(99999)).rejects.toThrow( - "Failed to fetch pokemon detail: 404" - ); + await expect(fetchPokemonDetail(99999)).rejects.toThrow("Failed to fetch pokemon detail: 404"); }); }); diff --git a/__tests__/detail/repository/pokemonSpeciesApi.test.ts b/__tests__/detail/repository/pokemonSpeciesApi.test.ts index 6faf083..2d7f551 100644 --- a/__tests__/detail/repository/pokemonSpeciesApi.test.ts +++ b/__tests__/detail/repository/pokemonSpeciesApi.test.ts @@ -11,116 +11,94 @@ const mockSpeciesResponse = { ], }; -const mockEmptyResponse = { +const mockEmptySpeciesResponse = { flavor_text_entries: [], names: [], }; const originalFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -describe("fetchPokemonFlavorText", () => { - it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), - }); - - await fetchPokemonFlavorText(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon-species/25" - ); +describe("pokemonSpeciesApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; }); - it("英語のフレーバーテキストを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), + describe("fetchPokemonFlavorText", () => { + it("正しいURLでfetchを呼び出す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + await fetchPokemonFlavorText(25); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon-species/25"); }); - const result = await fetchPokemonFlavorText(25); - - expect(result).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); - }); - - it("フレーバーテキストがない場合はnullを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), + it("英語のフレーバーテキストを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonFlavorText(25); + expect(result).toBe("When several of these POKéMON gather, their electricity could build and cause lightning storms."); }); - const result = await fetchPokemonFlavorText(25); - - expect(result).toBeNull(); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, + it("フレーバーテキストがない場合はnullを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptySpeciesResponse), + }); + const result = await fetchPokemonFlavorText(25); + expect(result).toBeNull(); }); - await expect(fetchPokemonFlavorText(99999)).rejects.toThrow( - "Failed to fetch pokemon species: 404" - ); - }); -}); - -describe("fetchPokemonSpeciesInfo", () => { - it("指定した言語のポケモン名とフレーバーテキストを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonFlavorText(99999)).rejects.toThrow("Failed to fetch pokemon species: 404"); }); - - const result = await fetchPokemonSpeciesInfo(25, "ja"); - - expect(result.localizedName).toBe("ピカチュウ"); - expect(result.flavorText).toBe("でんきを ためこむ せいしつ。"); }); - it("英語を指定した場合に英語のデータを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockSpeciesResponse), + describe("fetchPokemonSpeciesInfo", () => { + it("指定した言語のポケモン名とフレーバーテキストを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "ja"); + expect(result.localizedName).toBe("ピカチュウ"); + expect(result.flavorText).toBe("でんきを ためこむ せいしつ。"); }); - const result = await fetchPokemonSpeciesInfo(25, "en"); - - expect(result.localizedName).toBe("Pikachu"); - expect(result.flavorText).toBe( - "When several of these POKéMON gather, their electricity could build and cause lightning storms." - ); - }); - - it("該当する言語がない場合はnullを返す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockEmptyResponse), + it("英語を指定した場合に英語のデータを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockSpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "en"); + expect(result.localizedName).toBe("Pikachu"); + expect(result.flavorText).toBe( + "When several of these POKéMON gather, their electricity could build and cause lightning storms." + ); }); - const result = await fetchPokemonSpeciesInfo(25, "ja"); - - expect(result.localizedName).toBeNull(); - expect(result.flavorText).toBeNull(); - }); - - it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, + it("該当する言語がない場合はnullを返す", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockEmptySpeciesResponse), + }); + const result = await fetchPokemonSpeciesInfo(25, "ja"); + expect(result.localizedName).toBeNull(); + expect(result.flavorText).toBeNull(); }); - await expect(fetchPokemonSpeciesInfo(99999, "ja")).rejects.toThrow( - "Failed to fetch pokemon species: 404" - ); + it("HTTPエラー時にエラーをスローする", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + status: 404, + }); + await expect(fetchPokemonSpeciesInfo(99999, "ja")).rejects.toThrow("Failed to fetch pokemon species: 404"); + }); }); }); diff --git a/__tests__/detail/screens/DetailScreen.test.tsx b/__tests__/detail/screens/DetailScreen.test.tsx deleted file mode 100644 index a0ff14d..0000000 --- a/__tests__/detail/screens/DetailScreen.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { DetailScreen } from "@/src/detail"; -import type { Pokemon } from "@/src/shared"; - -const mockPokemon: Pokemon = { - id: 25, - name: "Pikachu", - types: ["electric"], - stats: [ - { name: "hp", baseStat: 35 }, - { name: "attack", baseStat: 55 }, - { name: "defense", baseStat: 40 }, - { name: "special-attack", baseStat: 50 }, - { name: "special-defense", baseStat: 50 }, - { name: "speed", baseStat: 90 }, - ], - height: 4, - weight: 60, - abilities: [ - { name: "static", isHidden: false }, - { name: "lightning-rod", isHidden: true }, - ], -}; - -const mockUsePokemonDetail = { - pokemon: mockPokemon as Pokemon | null, - isLoading: false, - error: null as string | null, -}; - -const mockUsePokemonSpeciesInfo = { - flavorText: "It keeps its tail raised." as string | null, - localizedName: "ピカチュウ" as string | null, - isLoading: false, -}; - -jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ - usePokemonDetail: () => mockUsePokemonDetail, -})); - -jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ - usePokemonSpeciesInfo: () => mockUsePokemonSpeciesInfo, -})); - -const renderWithProvider = (id: string) => render(); - -describe("DetailScreen", () => { - beforeEach(() => { - mockUsePokemonDetail.pokemon = mockPokemon; - mockUsePokemonDetail.isLoading = false; - mockUsePokemonDetail.error = null; - mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; - mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; - mockUsePokemonSpeciesInfo.isLoading = false; - }); - - it("ローカライズされたポケモン名が表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ポケモンのIDが表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("#025")).toBeTruthy(); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonDetail.isLoading = true; - mockUsePokemonDetail.pokemon = null; - renderWithProvider("25"); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("エラー時にエラーメッセージが表示される", () => { - mockUsePokemonDetail.pokemon = null; - mockUsePokemonDetail.error = "Not found"; - renderWithProvider("999"); - expect(screen.getByText("detail.notFound")).toBeTruthy(); - }); - - it("お気に入りボタンが表示される", () => { - renderWithProvider("25"); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("ステータスが詳細画面に表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("detail.baseStats")).toBeTruthy(); - }); - - it("身長と体重が詳細画面に表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("detail.height")).toBeTruthy(); - expect(screen.getByText("detail.weight")).toBeTruthy(); - }); - - it("フレーバーテキストが表示される", () => { - renderWithProvider("25"); - expect(screen.getByText("It keeps its tail raised.")).toBeTruthy(); - }); - - it("ローカライズ名がnullの場合はAPI名が表示される", () => { - mockUsePokemonSpeciesInfo.localizedName = null; - renderWithProvider("25"); - expect(screen.getByText("Pikachu")).toBeTruthy(); - }); -}); diff --git a/__tests__/detail/screens/detailScreen.feature b/__tests__/detail/screens/detailScreen.feature new file mode 100644 index 0000000..bf219de --- /dev/null +++ b/__tests__/detail/screens/detailScreen.feature @@ -0,0 +1,278 @@ +Feature: ポケモン詳細画面 + + # ============================================================ + # DetailScreen(画面統合テスト) + # ============================================================ + + Scenario: ローカライズされたポケモン名が表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「ピカチュウ」が表示される + + Scenario: ポケモンのIDが表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「#025」が表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ローディング中のモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then ローディングインジケータが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given エラー状態のモックが設定されている + When 詳細画面をID「999」でレンダリングする + Then 「detail.notFound」が表示される + + Scenario: お気に入りボタンが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then お気に入りボタンが詳細画面に表示される + + Scenario: ステータスが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.baseStats」が表示される + + Scenario: 身長と体重が詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「detail.height」が詳細画面に表示される + And 「detail.weight」が詳細画面に表示される + + Scenario: フレーバーテキストが詳細画面に表示される + Given ピカチュウの詳細データとローカライズ名がモックされている + When 詳細画面をID「25」でレンダリングする + Then 「It keeps its tail raised.」が表示される + + Scenario: ローカライズ名がnullの場合はAPI名が表示される + Given ローカライズ名がnullのモック状態が設定されている + When 詳細画面をID「25」でレンダリングする + Then 「Pikachu」が表示される + + # ============================================================ + # PokemonDetailコンポーネント + # ============================================================ + + Scenario: ローカライズ名が渡された場合に表示される + Given ピカチュウのデータが用意されている + When ローカライズ名「ピカチュウ」を指定してPokemonDetailをレンダリングする + Then PokemonDetailに「ピカチュウ」が表示される + + Scenario: ローカライズ名がnullの場合はPokemonDetailでAPI名が表示される + Given ピカチュウのデータが用意されている + When ローカライズ名をnullにしてPokemonDetailをレンダリングする + Then PokemonDetailに「pikachu」が表示される + + Scenario: ポケモンのIDが3桁ゼロ埋めで表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailに「#025」が表示される + + Scenario: ポケモンの画像が表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then ポケモン画像のURIに「25.png」が含まれる + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailに「types.electric」が表示される + + Scenario: 複数タイプが翻訳されて全て表示される + Given リザードンのデータが用意されている + When PokemonDetailをリザードンでレンダリングする + Then PokemonDetailに「types.fire」が表示される + And PokemonDetailに「types.flying」も表示される + + Scenario: PokemonDetailにお気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングする + Then PokemonDetailのお気に入りボタンが表示される + + Scenario: お気に入りボタン押下後にonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: お気に入りが未指定の場合ボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then PokemonDetailのお気に入りボタンが表示されない + + Scenario: フレーバーテキストが渡された場合にPokemonDetailで表示される + Given ピカチュウのデータが用意されている + When フレーバーテキスト付きでPokemonDetailをレンダリングする + Then PokemonDetailにフレーバーテキストが表示される + + Scenario: フレーバーテキストが未指定の場合は表示されない + Given ピカチュウのデータが用意されている + When PokemonDetailをレンダリングする + Then フレーバーテキストのローディングが表示されない + + # ============================================================ + # PokemonAbilitiesコンポーネント + # ============================================================ + + Scenario: とくせいセクションタイトルが表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせいセクションタイトル「detail.abilities」が表示される + + Scenario: とくせい名がキャピタライズされて表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + + Scenario: 隠れとくせいにラベルが付与される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される + + Scenario: 複数のとくせいが全て表示される + Given とくせいリストが与えられている + When PokemonAbilitiesをレンダリングする + Then とくせい名「Overgrow」が表示される + And 隠れとくせい「Chlorophyll detail.hiddenAbility」が表示される + + # ============================================================ + # PokemonFlavorTextコンポーネント + # ============================================================ + + Scenario: フレーバーテキストコンポーネントにテキストが表示される + Given フレーバーテキストが与えられている + When PokemonFlavorTextをレンダリングする + Then テキスト「でんきを ためこむ せいしつ。」が表示される + + Scenario: ローディング中にフレーバーテキストのインジケータが表示される + Given テキストがnullでローディング中である + When PokemonFlavorTextをレンダリングする + Then フレーバーテキストのローディングインジケータが表示される + + Scenario: テキストがnullでローディングでない場合は何も表示されない + Given テキストがnullでローディングでない + When PokemonFlavorTextをレンダリングする + Then 何も表示されない + + # ============================================================ + # PokemonPhysicalInfoコンポーネント + # ============================================================ + + Scenario: 身長の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「0.7detail.heightUnit」が表示される + + Scenario: 体重の値が表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「6.9detail.weightUnit」が表示される + + Scenario: 身長ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「detail.height」が表示される + + Scenario: 体重ラベルが表示される + Given 身長7、体重69のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「detail.weight」が表示される + + Scenario: 整数の身長が正しくフォーマットされる + Given 身長20、体重1000のポケモンデータが与えられている + When PokemonPhysicalInfoをレンダリングする + Then 体格情報「2.0detail.heightUnit」が表示される + And 体格情報「100.0detail.weightUnit」も表示される + + # ============================================================ + # PokemonStatsコンポーネント + # ============================================================ + + Scenario: ステータスセクションタイトルが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then ステータス「detail.baseStats」が表示される + + Scenario: 6つのステータスバーが表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then ステータス「detail.stats.hp」が表示される + And ステータス「detail.stats.attack」が表示される + And ステータス「detail.stats.defense」が表示される + And ステータス「detail.stats.special-attack」が表示される + And ステータス「detail.stats.special-defense」が表示される + And ステータス「detail.stats.speed」が表示される + + Scenario: 各ステータスの値が正しく表示される + Given ステータスデータが与えられている + When PokemonStatsをレンダリングする + Then 値「45」が2つ表示される + And 値「49」が2つ表示される + And 値「65」が2つ表示される + + # ============================================================ + # StatBarコンポーネント + # ============================================================ + + Scenario: StatBarにステータス名が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then StatBarに「HP」が表示される + + Scenario: StatBarにステータス値が表示される + Given ラベル「HP」、値45のStatBarが与えられている + When StatBarをレンダリングする + Then StatBarに「45」が表示される + + Scenario: バーの幅がステータス値に比例する + Given ラベル「HP」、値128、最大値256のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である + + Scenario: maxValueのデフォルトは180 + Given ラベル「Speed」、値90のStatBarが与えられている + When StatBarをレンダリングする + Then バーの幅が「50%」である + + # ============================================================ + # FavoriteButtonコンポーネント(shared) + # ============================================================ + + Scenario: Lottieアニメーションコンポーネントが描画される + Given 非お気に入り状態のFavoriteButtonを描画する + Then Lottieアニメーションコンポーネントが存在する + + Scenario: autoPlayが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then autoPlayがfalseである + + Scenario: ループが無効になっている + Given 非お気に入り状態のFavoriteButtonを描画する + Then loopがfalseである + + Scenario: ボタン押下時にonToggleが即座に呼ばれる + Given 非お気に入り状態のFavoriteButtonを描画する + When お気に入りボタンを押す + Then onToggleが1回呼ばれる + + Scenario: お気に入り状態の場合、ONの最終フレームで初期表示される + Given お気に入り状態のFavoriteButtonを描画する + Then progressがON最終フレームの値である + + Scenario: 非お気に入り状態の場合、progressが0で初期表示される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + + Scenario: 外部からisFavoriteが変更された場合、progressが再同期される + Given 非お気に入り状態のFavoriteButtonを描画する + Then progressが0である + When isFavoriteをtrueに変更する + Then progressがON最終フレームの値である + + Scenario: お気に入り状態のアクセシビリティラベルが正しい + Given お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.remove" である + + Scenario: 非お気に入り状態のアクセシビリティラベルが正しい + Given 非お気に入り状態のFavoriteButtonを描画する + Then アクセシビリティラベルが "favoriteButton.add" である diff --git a/__tests__/detail/screens/detailScreen.steps.tsx b/__tests__/detail/screens/detailScreen.steps.tsx new file mode 100644 index 0000000..7576249 --- /dev/null +++ b/__tests__/detail/screens/detailScreen.steps.tsx @@ -0,0 +1,950 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { DetailScreen } from "@/src/detail"; +import { PokemonDetail } from "@/src/detail/components/PokemonDetail"; +import { PokemonAbilities } from "@/src/detail/components/PokemonAbilities"; +import { PokemonFlavorText } from "@/src/detail/components/PokemonFlavorText"; +import { PokemonPhysicalInfo } from "@/src/detail/components/PokemonPhysicalInfo"; +import { PokemonStats } from "@/src/detail/components/PokemonStats"; +import { StatBar } from "@/src/detail/components/StatBar"; +import { usePokemonDetail } from "@/src/detail/hooks/usePokemonDetail"; +import { usePokemonSpeciesInfo } from "@/src/detail/hooks/usePokemonSpeciesInfo"; +import { FavoriteButton } from "@/src/shared"; +import type { Pokemon, PokemonAbility, PokemonStat } from "@/src/shared"; + +// ============================================================ +// jest.mock — ファイルトップレベル +// ============================================================ + +jest.mock("@/src/detail/repository/pokemonDetailApi"); +jest.mock("@/src/detail/repository/pokemonSpeciesApi"); + +// DetailScreen用モック +const mockUsePokemonDetail = { + pokemon: null as Pokemon | null, + isLoading: false, + error: null as string | null, +}; + +const mockUsePokemonSpeciesInfo = { + flavorText: "It keeps its tail raised." as string | null, + localizedName: "ピカチュウ" as string | null, + isLoading: false, +}; + +jest.mock("@/src/detail/hooks/usePokemonDetail", () => ({ + usePokemonDetail: jest.fn(() => mockUsePokemonDetail), +})); + +jest.mock("@/src/detail/hooks/usePokemonSpeciesInfo", () => ({ + usePokemonSpeciesInfo: jest.fn(() => mockUsePokemonSpeciesInfo), +})); + +jest.mock("@/src/detail/hooks/usePokemonFlavorText", () => ({ + usePokemonFlavorText: jest.fn(), +})); + +// ============================================================ +// 共通テストデータ +// ============================================================ + +const mockPokemonForDetail: Pokemon = { + id: 25, + name: "pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const multiTypePokemon: Pokemon = { + id: 6, + name: "charizard", + types: ["fire", "flying"], + stats: [ + { name: "hp", baseStat: 78 }, + { name: "attack", baseStat: 84 }, + { name: "defense", baseStat: 78 }, + { name: "special-attack", baseStat: 109 }, + { name: "special-defense", baseStat: 85 }, + { name: "speed", baseStat: 100 }, + ], + height: 17, + weight: 905, + abilities: [ + { name: "blaze", isHidden: false }, + { name: "solar-power", isHidden: true }, + ], +}; + +const mockScreenPokemon: Pokemon = { + id: 25, + name: "Pikachu", + types: ["electric"], + stats: [ + { name: "hp", baseStat: 35 }, + { name: "attack", baseStat: 55 }, + { name: "defense", baseStat: 40 }, + { name: "special-attack", baseStat: 50 }, + { name: "special-defense", baseStat: 50 }, + { name: "speed", baseStat: 90 }, + ], + height: 4, + weight: 60, + abilities: [ + { name: "static", isHidden: false }, + { name: "lightning-rod", isHidden: true }, + ], +}; + +const mockAbilities: PokemonAbility[] = [ + { name: "overgrow", isHidden: false }, + { name: "chlorophyll", isHidden: true }, +]; + +const mockStats: PokemonStat[] = [ + { name: "hp", baseStat: 45 }, + { name: "attack", baseStat: 49 }, + { name: "defense", baseStat: 49 }, + { name: "special-attack", baseStat: 65 }, + { name: "special-defense", baseStat: 65 }, + { name: "speed", baseStat: 45 }, +]; + +// ============================================================ +// Feature 定義 +// ============================================================ + +const feature = loadFeature( + "__tests__/detail/screens/detailScreen.feature" +); + +const renderWithProvider = (id: string) => render(); + +defineFeature(feature, (test) => { + // ============================================================ + // DetailScreen + // ============================================================ + + beforeEach(() => { + mockUsePokemonDetail.pokemon = mockScreenPokemon; + mockUsePokemonDetail.isLoading = false; + mockUsePokemonDetail.error = null; + mockUsePokemonSpeciesInfo.flavorText = "It keeps its tail raised."; + mockUsePokemonSpeciesInfo.localizedName = "ピカチュウ"; + mockUsePokemonSpeciesInfo.isLoading = false; + }); + + test("ローカライズされたポケモン名が表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { + given("ローディング中のモック状態が設定されている", () => { + mockUsePokemonDetail.isLoading = true; + mockUsePokemonDetail.pokemon = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("ローディングインジケータが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("エラー状態のモックが設定されている", () => { + mockUsePokemonDetail.pokemon = null; + mockUsePokemonDetail.error = "Not found"; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("お気に入りボタンが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then("お気に入りボタンが詳細画面に表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("ステータスが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長と体重が詳細画面に表示される", ({ given, when, then, and }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が詳細画面に表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^「(.*)」が詳細画面に表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("フレーバーテキストが詳細画面に表示される", ({ given, when, then }) => { + given("ピカチュウの詳細データとローカライズ名がモックされている", () => { + // defaults from beforeEach + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はAPI名が表示される", ({ given, when, then }) => { + given("ローカライズ名がnullのモック状態が設定されている", () => { + mockUsePokemonSpeciesInfo.localizedName = null; + }); + + when(/^詳細画面をID「(.*)」でレンダリングする$/, (id: string) => { + renderWithProvider(id); + }); + + then(/^「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonDetailコンポーネント + // ============================================================ + + let onToggleFavorite: jest.Mock; + + test("ローカライズ名が渡された場合に表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when(/^ローカライズ名「(.*)」を指定してPokemonDetailをレンダリングする$/, (name: string) => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ローカライズ名がnullの場合はPokemonDetailでAPI名が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("ローカライズ名をnullにしてPokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンのIDが3桁ゼロ埋めで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^ポケモン画像のURIに「(.*)」が含まれる$/, (fragment: string) => { + const image = screen.getByTestId("pokemon-detail-image"); + expect(image.props.source.uri).toContain(fragment); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプが翻訳されて全て表示される", ({ given, when, then, and }) => { + given("リザードンのデータが用意されている", () => { + // multiTypePokemon is predefined + }); + + when("PokemonDetailをリザードンでレンダリングする", () => { + render(); + }); + + then(/^PokemonDetailに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^PokemonDetailに「(.*)」も表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("PokemonDetailにお気に入りボタンが表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングする", () => { + render( + , + ); + }); + + then("PokemonDetailのお気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後にonToggleFavoriteが呼ばれる", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + onToggleFavorite = jest.fn(); + }); + + when("お気に入り機能付きでPokemonDetailをレンダリングしてボタンを押す", () => { + render( + , + ); + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入りが未指定の場合ボタンが表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("PokemonDetailのお気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); + + test("フレーバーテキストが渡された場合にPokemonDetailで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("フレーバーテキスト付きでPokemonDetailをレンダリングする", () => { + render(); + }); + + then("PokemonDetailにフレーバーテキストが表示される", () => { + expect(screen.getByText("でんきを ためこむ せいしつ。")).toBeTruthy(); + }); + }); + + test("フレーバーテキストが未指定の場合は表示されない", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + // mockPokemonForDetail is predefined + }); + + when("PokemonDetailをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストのローディングが表示されない", () => { + expect(screen.queryByTestId("flavor-text-loading")).toBeNull(); + }); + }); + + // ============================================================ + // PokemonAbilitiesコンポーネント + // ============================================================ + + test("とくせいセクションタイトルが表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせいセクションタイトル「(.*)」が表示される$/, (title: string) => { + expect(screen.getByText(title)).toBeTruthy(); + }); + }); + + test("とくせい名がキャピタライズされて表示される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("隠れとくせいにラベルが付与される", ({ given, when, then }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数のとくせいが全て表示される", ({ given, when, then, and }) => { + given("とくせいリストが与えられている", () => { + // mockAbilities is predefined + }); + + when("PokemonAbilitiesをレンダリングする", () => { + render(); + }); + + then(/^とくせい名「(.*)」が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^隠れとくせい「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonFlavorTextコンポーネント + // ============================================================ + + let flavorTextValue: string | null; + let flavorTextIsLoading: boolean; + let flavorTextRenderResult: ReturnType | null; + + test("フレーバーテキストコンポーネントにテキストが表示される", ({ given, when, then }) => { + given("フレーバーテキストが与えられている", () => { + flavorTextValue = "でんきを ためこむ せいしつ。"; + flavorTextIsLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then(/^テキスト「(.*)」が表示される$/, (expected: string) => { + expect(screen.getByText(expected)).toBeTruthy(); + }); + }); + + test("ローディング中にフレーバーテキストのインジケータが表示される", ({ given, when, then }) => { + given("テキストがnullでローディング中である", () => { + flavorTextValue = null; + flavorTextIsLoading = true; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + render(); + }); + + then("フレーバーテキストのローディングインジケータが表示される", () => { + expect(screen.getByTestId("flavor-text-loading")).toBeTruthy(); + }); + }); + + test("テキストがnullでローディングでない場合は何も表示されない", ({ given, when, then }) => { + given("テキストがnullでローディングでない", () => { + flavorTextValue = null; + flavorTextIsLoading = false; + }); + + when("PokemonFlavorTextをレンダリングする", () => { + flavorTextRenderResult = render(); + }); + + then("何も表示されない", () => { + expect(flavorTextRenderResult!.toJSON()).toBeNull(); + }); + }); + + // ============================================================ + // PokemonPhysicalInfoコンポーネント + // ============================================================ + + let physicalHeight: number; + let physicalWeight: number; + + test("身長の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重の値が表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("身長ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("体重ラベルが表示される", ({ given, when, then }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("整数の身長が正しくフォーマットされる", ({ given, when, then, and }) => { + given(/^身長(\d+)、体重(\d+)のポケモンデータが与えられている$/, (h: string, w: string) => { + physicalHeight = Number(h); + physicalWeight = Number(w); + }); + + when("PokemonPhysicalInfoをレンダリングする", () => { + render(); + }); + + then(/^体格情報「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^体格情報「(.*)」も表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + // ============================================================ + // PokemonStatsコンポーネント + // ============================================================ + + test("ステータスセクションタイトルが表示される", ({ given, when, then }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("6つのステータスバーが表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^ステータス「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("各ステータスの値が正しく表示される", ({ given, when, then, and }) => { + given("ステータスデータが与えられている", () => { + // mockStats is predefined + }); + + when("PokemonStatsをレンダリングする", () => { + render(); + }); + + then(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + + and(/^値「(\d+)」が(\d+)つ表示される$/, (value: string, count: string) => { + expect(screen.getAllByText(value)).toHaveLength(Number(count)); + }); + }); + + // ============================================================ + // StatBarコンポーネント + // ============================================================ + + let statBarLabel: string; + let statBarValue: number; + let statBarMaxValue: number | undefined; + + test("StatBarにステータス名が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^StatBarに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("StatBarにステータス値が表示される", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^StatBarに「(.*)」が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("バーの幅がステータス値に比例する", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)、最大値(\d+)のStatBarが与えられている$/, (l: string, v: string, m: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = Number(m); + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); + + test("maxValueのデフォルトは180", ({ given, when, then }) => { + given(/^ラベル「(.*)」、値(\d+)のStatBarが与えられている$/, (l: string, v: string) => { + statBarLabel = l; + statBarValue = Number(v); + statBarMaxValue = undefined; + }); + + when("StatBarをレンダリングする", () => { + render(); + }); + + then(/^バーの幅が「(.*)」である$/, (width: string) => { + const bar = screen.getByTestId("stat-bar-fill"); + expect(bar.props.style).toEqual( + expect.arrayContaining([ + expect.objectContaining({ width }), + ]) + ); + }); + }); + + // ============================================================ + // FavoriteButtonコンポーネント(shared) + // ============================================================ + + let favoriteOnToggle: jest.Mock; + let favoriteRerender: ReturnType["rerender"]; + + test("Lottieアニメーションコンポーネントが描画される", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("Lottieアニメーションコンポーネントが存在する", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("autoPlayが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("autoPlayがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.autoPlay).toBe(false); + }); + }); + + test("ループが無効になっている", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("loopがfalseである", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.loop).toBe(false); + }); + }); + + test("ボタン押下時にonToggleが即座に呼ばれる", ({ given, when, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + when("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleが1回呼ばれる", () => { + expect(favoriteOnToggle).toHaveBeenCalledTimes(1); + }); + }); + + test("お気に入り状態の場合、ONの最終フレームで初期表示される", ({ given, then }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("progressがON最終フレームの値である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBeCloseTo(90 / 181); + }); + }); + + test("非お気に入り状態の場合、progressが0で初期表示される", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then("progressが0である", () => { + const lottie = screen.getByTestId("favorite-lottie"); + expect(lottie.props.progress).toBe(0); + }); + }); + + test("外部からisFavoriteが変更された場合、progressが再同期される", ({ given, then, when }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + const result = render( + + ); + favoriteRerender = result.rerender; + }); + + then("progressが0である", () => { + expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); + }); + + when("isFavoriteをtrueに変更する", () => { + favoriteRerender(); + }); + + then("progressがON最終フレームの値である", () => { + expect( + screen.getByTestId("favorite-lottie").props.progress + ).toBeCloseTo(90 / 181); + }); + }); + + test("お気に入り状態のアクセシビリティラベルが正しい", ({ given, then }) => { + given("お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); + + test("非お気に入り状態のアクセシビリティラベルが正しい", ({ given, then }) => { + given("非お気に入り状態のFavoriteButtonを描画する", () => { + favoriteOnToggle = jest.fn(); + render(); + }); + + then( + /^アクセシビリティラベルが "(.*)" である$/, + (label: string) => { + expect(screen.getByLabelText(label)).toBeTruthy(); + } + ); + }); +}); diff --git a/__tests__/favorites/hooks/usePokemonByIds.test.ts b/__tests__/favorites/hooks/usePokemonByIds.test.ts index 1aac339..a061dc2 100644 --- a/__tests__/favorites/hooks/usePokemonByIds.test.ts +++ b/__tests__/favorites/hooks/usePokemonByIds.test.ts @@ -1,12 +1,17 @@ import { renderHook, waitFor, act } from "@testing-library/react-native"; -import { usePokemonByIds } from "@/src/favorites/hooks/usePokemonByIds"; -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; -import { fetchPokemonSpeciesInfo } from "@/src/shared/repository/pokemonSpeciesApi"; +import { fetchPokemonById, fetchPokemonSpeciesInfo } from "@/src/shared"; import type { PokemonSummary, PokemonSpeciesInfo } from "@/src/shared"; jest.mock("@/src/shared/repository/pokemonApi"); jest.mock("@/src/shared/repository/pokemonSpeciesApi"); +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + language: "ja", + }, +})); + const mockFetchById = fetchPokemonById as jest.MockedFunction; const mockFetchSpecies = fetchPokemonSpeciesInfo as jest.MockedFunction; @@ -20,6 +25,15 @@ const mockSpeciesJa: PokemonSpeciesInfo[] = [ { localizedName: "フシギダネ", flavorText: null }, ]; +// usePokemonByIdsはバレルにエクスポートされていないため直接import +// eslint-disable-next-line @typescript-eslint/no-require-imports +function getUsePokemonByIds() { + const { usePokemonByIds } = jest.requireActual< + typeof import("@/src/favorites/hooks/usePokemonByIds") + >("@/src/favorites/hooks/usePokemonByIds"); + return usePokemonByIds; +} + describe("usePokemonByIds", () => { beforeEach(() => { mockFetchById.mockReset(); @@ -27,8 +41,8 @@ describe("usePokemonByIds", () => { }); it("空配列の場合はローディングせず空配列を返す", () => { + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([])); - expect(result.current.isLoading).toBe(false); expect(result.current.pokemon).toEqual([]); }); @@ -41,12 +55,12 @@ describe("usePokemonByIds", () => { .mockResolvedValueOnce(mockSpeciesJa[0]) .mockResolvedValueOnce(mockSpeciesJa[1]); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25, 1])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.pokemon).toHaveLength(2); expect(mockFetchById).toHaveBeenCalledTimes(2); expect(mockFetchSpecies).toHaveBeenCalledTimes(2); @@ -56,12 +70,12 @@ describe("usePokemonByIds", () => { mockFetchById.mockResolvedValueOnce(mockPokemon[0]); mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.pokemon[0].name).toBe("ピカチュウ"); }); @@ -69,12 +83,12 @@ describe("usePokemonByIds", () => { mockFetchById.mockResolvedValueOnce(mockPokemon[0]); mockFetchSpecies.mockResolvedValueOnce({ localizedName: null, flavorText: null }); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.pokemon[0].name).toBe("Pikachu"); }); @@ -82,12 +96,12 @@ describe("usePokemonByIds", () => { mockFetchById.mockResolvedValueOnce(mockPokemon[0]); mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(mockFetchSpecies).toHaveBeenCalledWith(25, "ja"); }); @@ -99,12 +113,12 @@ describe("usePokemonByIds", () => { .mockResolvedValueOnce(mockSpeciesJa[0]) .mockResolvedValueOnce(mockSpeciesJa[1]); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25, 999])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.error).toBe("Not found"); }); @@ -112,12 +126,12 @@ describe("usePokemonByIds", () => { mockFetchById.mockRejectedValueOnce("string error"); mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + const usePokemonByIds = getUsePokemonByIds(); const { result } = renderHook(() => usePokemonByIds([25])); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.error).toBe("Unknown error"); }); @@ -125,11 +139,12 @@ describe("usePokemonByIds", () => { let resolve!: (value: PokemonSummary) => void; mockFetchById.mockReturnValue(new Promise((r) => { resolve = r; })); mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + + const usePokemonByIds = getUsePokemonByIds(); const { result, unmount } = renderHook(() => usePokemonByIds([25])); expect(result.current.isLoading).toBe(true); unmount(); - await act(async () => { resolve(mockPokemon[0]); }); expect(result.current.pokemon).toEqual([]); @@ -140,6 +155,7 @@ describe("usePokemonByIds", () => { mockFetchById.mockResolvedValueOnce(mockPokemon[0]); mockFetchSpecies.mockResolvedValueOnce(mockSpeciesJa[0]); + const usePokemonByIds = getUsePokemonByIds(); const { result, rerender } = renderHook( (props: { ids: number[] }) => usePokemonByIds(props.ids), { initialProps: { ids: [25] } } @@ -148,7 +164,6 @@ describe("usePokemonByIds", () => { await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.pokemon).toHaveLength(1); mockFetchById diff --git a/__tests__/favorites/screens/FavoritesScreen.test.tsx b/__tests__/favorites/screens/FavoritesScreen.test.tsx deleted file mode 100644 index eb35155..0000000 --- a/__tests__/favorites/screens/FavoritesScreen.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from "@testing-library/react-native"; -import { FavoritesScreen } from "@/src/favorites"; -import type { PokemonSummary } from "@/src/shared"; - -const mockUsePokemonByIds = { - pokemon: [] as PokemonSummary[], - isLoading: false, - error: null as string | null, -}; - -jest.mock("@/src/favorites/hooks/usePokemonByIds", () => ({ - usePokemonByIds: () => mockUsePokemonByIds, -})); - -jest.mock("expo-router", () => ({ - Link: ({ - children, - href, - }: { - children: React.ReactNode; - href: string; - asChild?: boolean; - }) => { - const { View } = require("react-native"); - return {children}; - }, -})); - -const renderWithProvider = () => render(); - -describe("FavoritesScreen", () => { - beforeEach(() => { - mockUsePokemonByIds.pokemon = []; - mockUsePokemonByIds.isLoading = false; - mockUsePokemonByIds.error = null; - }); - - it("お気に入りが空の場合、プレースホルダーが表示される", () => { - renderWithProvider(); - expect( - screen.getByText("favorites.empty"), - ).toBeTruthy(); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonByIds.isLoading = true; - renderWithProvider(); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("お気に入りのポケモンがカードとして表示される", () => { - mockUsePokemonByIds.pokemon = [ - { id: 25, name: "Pikachu", types: ["electric"] }, - ]; - renderWithProvider(); - expect(screen.getByText("Pikachu")).toBeTruthy(); - }); -}); diff --git a/__tests__/favorites/screens/favoritesScreen.feature b/__tests__/favorites/screens/favoritesScreen.feature new file mode 100644 index 0000000..c55bdcb --- /dev/null +++ b/__tests__/favorites/screens/favoritesScreen.feature @@ -0,0 +1,16 @@ +Feature: お気に入り画面 + + Scenario: お気に入りが空の場合、プレースホルダーが表示される + Given お気に入りポケモンがない + When お気に入り画面を表示する + Then プレースホルダーテキストが表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given データをローディング中である + When お気に入り画面を表示する + Then ローディングインジケーターが表示される + + Scenario: お気に入りのポケモンがカードとして表示される + Given お気に入りにピカチュウが登録されている + When お気に入り画面を表示する + Then "Pikachu" のカードが表示される diff --git a/__tests__/favorites/screens/favoritesScreen.steps.tsx b/__tests__/favorites/screens/favoritesScreen.steps.tsx new file mode 100644 index 0000000..cdd6dec --- /dev/null +++ b/__tests__/favorites/screens/favoritesScreen.steps.tsx @@ -0,0 +1,90 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen } from "@testing-library/react-native"; +import { FavoritesScreen } from "@/src/favorites"; +import type { PokemonSummary } from "@/src/shared"; + +const mockUsePokemonByIdsReturn = { + pokemon: [] as PokemonSummary[], + isLoading: false, + error: null as string | null, +}; + +jest.mock("@/src/favorites/hooks/usePokemonByIds", () => ({ + usePokemonByIds: jest.fn(() => mockUsePokemonByIdsReturn), +})); + +jest.mock("expo-router", () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + asChild?: boolean; + }) => { + const { View } = require("react-native"); + return {children}; + }, +})); + +jest.mock("@/src/shared/i18n", () => ({ + i18n: { + t: (key: string) => key, + }, +})); + +const feature = loadFeature( + "__tests__/favorites/screens/favoritesScreen.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockUsePokemonByIdsReturn.pokemon = []; + mockUsePokemonByIdsReturn.isLoading = false; + mockUsePokemonByIdsReturn.error = null; + }); + + test("お気に入りが空の場合、プレースホルダーが表示される", ({ given, when, then }) => { + given("お気に入りポケモンがない", () => { + // default state: empty + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then("プレースホルダーテキストが表示される", () => { + expect(screen.getByText("favorites.empty")).toBeTruthy(); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ given, when, then }) => { + given("データをローディング中である", () => { + mockUsePokemonByIdsReturn.isLoading = true; + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then("ローディングインジケーターが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("お気に入りのポケモンがカードとして表示される", ({ given, when, then }) => { + given("お気に入りにピカチュウが登録されている", () => { + mockUsePokemonByIdsReturn.pokemon = [ + { id: 25, name: "Pikachu", types: ["electric"] }, + ]; + }); + + when("お気に入り画面を表示する", () => { + render(); + }); + + then(/^"Pikachu" のカードが表示される$/, () => { + expect(screen.getByText("Pikachu")).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/home/components/FloatingSearchButton.test.tsx b/__tests__/home/components/FloatingSearchButton.test.tsx deleted file mode 100644 index 2eb8d52..0000000 --- a/__tests__/home/components/FloatingSearchButton.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { render, screen, fireEvent, act } from "@testing-library/react-native"; -import { Keyboard, Platform } from "react-native"; -import { FloatingSearchButton } from "@/src/home/components/FloatingSearchButton"; - -describe("FloatingSearchButton", () => { - const defaultProps = { - searchText: "", - onChangeText: jest.fn(), - placeholder: "Search...", - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("FABボタンが表示される", () => { - render(); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - - it("FABをタップすると検索入力が表示される", () => { - render(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - - it("検索入力にテキストを入力するとonChangeTextが呼ばれる", () => { - const onChangeText = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.changeText(screen.getByTestId("search-input"), "Pika"); - expect(onChangeText).toHaveBeenCalledWith("Pika"); - }); - - it("閉じるボタンをタップすると折りたたまれテキストがクリアされる", () => { - const onChangeText = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.press(screen.getByTestId("search-close-button")); - expect(onChangeText).toHaveBeenCalledWith(""); - }); - - it("プレースホルダーが表示される", () => { - render(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByPlaceholderText("Search...")).toBeTruthy(); - }); - - it("キーボードが閉じたらFABボタンに戻る", () => { - const onChangeText = jest.fn(); - const hideEvent = - Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; - - let hideCallback: (() => void) | undefined; - const addListenerSpy = jest.spyOn(Keyboard, "addListener"); - addListenerSpy.mockImplementation((event, callback) => { - if (event === hideEvent) { - hideCallback = callback as () => void; - } - return { remove: jest.fn() } as unknown as ReturnType< - typeof Keyboard.addListener - >; - }); - - render( - , - ); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - - act(() => { - hideCallback?.(); - }); - - expect(onChangeText).toHaveBeenCalledWith(""); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - - addListenerSpy.mockRestore(); - }); -}); diff --git a/__tests__/home/domain/pokemonListItem.test.ts b/__tests__/home/domain/pokemonListItem.test.ts index 36e7dd5..77167f0 100644 --- a/__tests__/home/domain/pokemonListItem.test.ts +++ b/__tests__/home/domain/pokemonListItem.test.ts @@ -1,59 +1,38 @@ -import { - extractPokemonId, - capitalizeName, - toPokemon, -} from "@/src/home/domain/pokemonListItem"; +import { extractPokemonId, capitalizeName, toPokemon } from "@/src/home"; describe("extractPokemonId", () => { it("URLからポケモンIDを数値として抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/25/") - ).toBe(25); + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/25/")).toBe(25); }); - it("別のIDでも正しく抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/151/") - ).toBe(151); + it("末尾スラッシュなしのURLからもIDを抽出する", () => { + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/1")).toBe(1); }); - it("末尾スラッシュなしのURLでも正しく抽出する", () => { - expect( - extractPokemonId("https://pokeapi.co/api/v2/pokemon/1") - ).toBe(1); + it("3桁のIDも正しく抽出する", () => { + expect(extractPokemonId("https://pokeapi.co/api/v2/pokemon/151/")).toBe(151); }); }); describe("capitalizeName", () => { - it("小文字の名前を先頭大文字化する", () => { + it("先頭文字を大文字にする", () => { expect(capitalizeName("bulbasaur")).toBe("Bulbasaur"); }); - it("空文字列を処理できる", () => { + it("空文字の場合はそのまま返す", () => { expect(capitalizeName("")).toBe(""); }); }); describe("toPokemon", () => { - it("PokeApiListItemをPokemon型に変換する", () => { + it("PokeApiListItemをPokemonSummary型に変換する", () => { const result = toPokemon({ name: "pikachu", url: "https://pokeapi.co/api/v2/pokemon/25/", }); - expect(result).toEqual({ - id: 25, - name: "Pikachu", - types: [], - }); - }); - - it("typesは空配列になる", () => { - const result = toPokemon({ - name: "bulbasaur", - url: "https://pokeapi.co/api/v2/pokemon/1/", - }); - + expect(result.id).toBe(25); + expect(result.name).toBe("Pikachu"); expect(result.types).toEqual([]); }); }); diff --git a/__tests__/home/hooks/useFloatingSearch.test.ts b/__tests__/home/hooks/useFloatingSearch.test.ts index 25d3c1e..598732d 100644 --- a/__tests__/home/hooks/useFloatingSearch.test.ts +++ b/__tests__/home/hooks/useFloatingSearch.test.ts @@ -1,9 +1,10 @@ import { renderHook, act } from "@testing-library/react-native"; -import { useFloatingSearch } from "@/src/home/hooks/useFloatingSearch"; +import { useFloatingSearch } from "@/src/home"; describe("useFloatingSearch", () => { - it("初期状態でisExpandedがfalse", () => { + it("初期状態でisExpandedがfalseである", () => { const { result } = renderHook(() => useFloatingSearch()); + expect(result.current.isExpanded).toBe(false); }); @@ -27,11 +28,11 @@ describe("useFloatingSearch", () => { act(() => { result.current.toggle(); }); - expect(result.current.isExpanded).toBe(true); act(() => { result.current.close(); }); + expect(result.current.isExpanded).toBe(false); }); @@ -41,11 +42,13 @@ describe("useFloatingSearch", () => { act(() => { result.current.close(); }); + expect(result.current.isExpanded).toBe(false); }); it("アニメーションスタイルを返す", () => { const { result } = renderHook(() => useFloatingSearch()); + expect(result.current.fabAnimatedStyle).toBeDefined(); expect(result.current.iconAnimatedStyle).toBeDefined(); expect(result.current.inputAnimatedStyle).toBeDefined(); diff --git a/__tests__/home/hooks/usePokemonList.test.ts b/__tests__/home/hooks/usePokemonList.test.ts index 67f15b6..b5dc871 100644 --- a/__tests__/home/hooks/usePokemonList.test.ts +++ b/__tests__/home/hooks/usePokemonList.test.ts @@ -1,17 +1,17 @@ import { renderHook, act, waitFor } from "@testing-library/react-native"; -import { usePokemonList } from "@/src/home/hooks/usePokemonList"; +import { usePokemonList } from "@/src/home"; import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; import type { PokemonListResult } from "@/src/home/repository/pokemonGraphqlApi"; jest.mock("@/src/home/repository/pokemonGraphqlApi"); -const mockFetch = fetchPokemonListGraphQL as jest.MockedFunction< +const mockFetchGraphQL = fetchPokemonListGraphQL as jest.MockedFunction< typeof fetchPokemonListGraphQL >; const makePage = ( offset: number, - totalCount: number, + totalCount: number ): PokemonListResult => ({ count: totalCount, pokemon: [ @@ -22,18 +22,20 @@ const makePage = ( describe("usePokemonList", () => { beforeEach(() => { - mockFetch.mockReset(); + mockFetchGraphQL.mockReset(); }); it("初期ロード時にisLoadingがtrueになる", () => { - mockFetch.mockReturnValue(new Promise(() => {})); + mockFetchGraphQL.mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => usePokemonList()); expect(result.current.isLoading).toBe(true); }); it("データ取得後にポケモン一覧が設定される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { @@ -47,27 +49,31 @@ describe("usePokemonList", () => { }); it("言語パラメータがGraphQL関数に渡される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + renderHook(() => usePokemonList()); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith(20, 0, "ja"); + expect(mockFetchGraphQL).toHaveBeenCalled(); }); + + expect(mockFetchGraphQL).toHaveBeenCalledWith(20, 0, "ja"); }); it("loadMoreで追加データが追加される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - mockFetch.mockResolvedValueOnce(makePage(20, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(20, 40)); + await act(async () => { result.current.loadMore(); }); - await waitFor(() => { expect(result.current.isLoadingMore).toBe(false); }); @@ -76,7 +82,8 @@ describe("usePokemonList", () => { }); it("総件数に達した場合hasMoreがfalseになる", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 2)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { @@ -87,7 +94,8 @@ describe("usePokemonList", () => { }); it("hasMoreがfalseの場合loadMoreは何もしない", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 2)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 2)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { @@ -98,33 +106,35 @@ describe("usePokemonList", () => { result.current.loadMore(); }); - expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetchGraphQL).toHaveBeenCalledTimes(1); }); it("refreshでデータがリセットされる", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + await act(async () => { result.current.refresh(); }); - await waitFor(() => { expect(result.current.isRefreshing).toBe(false); }); expect(result.current.pokemon).toHaveLength(2); - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenLastCalledWith(20, 0, "ja"); + expect(mockFetchGraphQL).toHaveBeenCalledTimes(2); + expect(mockFetchGraphQL).toHaveBeenLastCalledWith(20, 0, "ja"); }); it("エラー時にerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + mockFetchGraphQL.mockRejectedValueOnce(new Error("Network error")); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { @@ -135,7 +145,8 @@ describe("usePokemonList", () => { }); it("初期ロードでError以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockRejectedValueOnce("string error"); + mockFetchGraphQL.mockRejectedValueOnce("string error"); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { @@ -146,18 +157,19 @@ describe("usePokemonList", () => { }); it("loadMoreでError以外のエラーでもerror状態が設定される", async () => { - mockFetch.mockResolvedValueOnce(makePage(0, 40)); + mockFetchGraphQL.mockResolvedValueOnce(makePage(0, 40)); + const { result } = renderHook(() => usePokemonList()); await waitFor(() => { expect(result.current.isLoading).toBe(false); }); - mockFetch.mockRejectedValueOnce("string error"); + mockFetchGraphQL.mockRejectedValueOnce("string error"); + await act(async () => { result.current.loadMore(); }); - await waitFor(() => { expect(result.current.isLoadingMore).toBe(false); }); diff --git a/__tests__/home/hooks/useSearch.test.ts b/__tests__/home/hooks/useSearch.test.ts index cf1b8df..8dba253 100644 --- a/__tests__/home/hooks/useSearch.test.ts +++ b/__tests__/home/hooks/useSearch.test.ts @@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react-native"; import { useSearch } from "@/src/home"; import type { PokemonSummary } from "@/src/shared"; -const mockPokemon: PokemonSummary[] = [ +const mockSearchPokemon: PokemonSummary[] = [ { id: 1, name: "フシギダネ", types: ["grass", "poison"] }, { id: 4, name: "ヒトカゲ", types: ["fire"] }, { id: 7, name: "ゼニガメ", types: ["water"] }, @@ -11,45 +11,55 @@ const mockPokemon: PokemonSummary[] = [ describe("useSearch", () => { it("初期状態では全てのポケモンが返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); - expect(result.current.filteredItems).toEqual(mockPokemon); + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + + expect(result.current.filteredItems).toEqual(mockSearchPokemon); expect(result.current.searchText).toBe(""); }); it("検索テキストに一致するポケモンのみがフィルタリングされる", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + act(() => { result.current.setSearchText("ピカチュウ"); }); + expect(result.current.filteredItems).toEqual([ { id: 25, name: "ピカチュウ", types: ["electric"] }, ]); }); it("検索テキストが空文字の場合は全てのポケモンが返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + act(() => { result.current.setSearchText("ピカ"); }); + act(() => { result.current.setSearchText(""); }); - expect(result.current.filteredItems).toEqual(mockPokemon); + + expect(result.current.filteredItems).toEqual(mockSearchPokemon); }); it("一致するポケモンがない場合は空配列が返される", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + act(() => { result.current.setSearchText("ミュウツー"); }); + expect(result.current.filteredItems).toEqual([]); }); it("検索テキストが部分一致でもフィルタリングされる", () => { - const { result } = renderHook(() => useSearch(mockPokemon)); + const { result } = renderHook(() => useSearch(mockSearchPokemon)); + act(() => { result.current.setSearchText("ガメ"); }); + expect(result.current.filteredItems).toEqual([ { id: 7, name: "ゼニガメ", types: ["water"] }, ]); diff --git a/__tests__/home/repository/pokemonApi.test.ts b/__tests__/home/repository/pokemonApi.test.ts index ea38ad1..3e62241 100644 --- a/__tests__/home/repository/pokemonApi.test.ts +++ b/__tests__/home/repository/pokemonApi.test.ts @@ -1,7 +1,7 @@ -import { fetchPokemonList } from "@/src/home/repository/pokemonApi"; -import type { PokeApiListResponse } from "@/src/home/domain/pokemonListItem"; +import { fetchPokemonList } from "@/src/home"; +import type { PokeApiListResponse } from "@/src/home"; -const mockResponse: PokeApiListResponse = { +const mockRestResponse: PokeApiListResponse = { count: 1302, next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20", previous: null, @@ -13,19 +13,19 @@ const mockResponse: PokeApiListResponse = { const originalFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); +describe("fetchPokemonList", () => { + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); -afterEach(() => { - globalThis.fetch = originalFetch; -}); + afterEach(() => { + globalThis.fetch = originalFetch; + }); -describe("fetchPokemonList", () => { it("正しいURLでfetchを呼び出す", async () => { (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockResponse), + json: () => Promise.resolve(mockRestResponse), }); await fetchPokemonList(20, 0); @@ -38,12 +38,11 @@ describe("fetchPokemonList", () => { it("レスポンスをパースして返す", async () => { (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockResponse), + json: () => Promise.resolve(mockRestResponse), }); const result = await fetchPokemonList(20, 0); - - expect(result).toEqual(mockResponse); + expect(result).toEqual(mockRestResponse); }); it("HTTPエラー時にエラーをスローする", async () => { diff --git a/__tests__/home/repository/pokemonGraphqlApi.test.ts b/__tests__/home/repository/pokemonGraphqlApi.test.ts index 4cbd34c..43bb18e 100644 --- a/__tests__/home/repository/pokemonGraphqlApi.test.ts +++ b/__tests__/home/repository/pokemonGraphqlApi.test.ts @@ -1,6 +1,4 @@ -import { - fetchPokemonListGraphQL, -} from "@/src/home/repository/pokemonGraphqlApi"; +import { fetchPokemonListGraphQL } from "@/src/home/repository/pokemonGraphqlApi"; const mockGraphQLResponse = { data: { @@ -22,9 +20,7 @@ const mockGraphQLResponse = { name: "charmander", pokemon_v2_pokemonspeciesnames: [{ name: "ヒトカゲ" }], }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "fire" } }, - ], + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "fire" } }], }, ], pokemon_v2_pokemon_aggregate: { @@ -42,9 +38,7 @@ const mockEmptyNameResponse = { name: "bulbasaur", pokemon_v2_pokemonspeciesnames: [], }, - pokemon_v2_pokemontypes: [ - { pokemon_v2_type: { name: "grass" } }, - ], + pokemon_v2_pokemontypes: [{ pokemon_v2_type: { name: "grass" } }], }, ], pokemon_v2_pokemon_aggregate: { @@ -55,15 +49,15 @@ const mockEmptyNameResponse = { const originalFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); +describe("fetchPokemonListGraphQL", () => { + beforeEach(() => { + globalThis.fetch = jest.fn(); + }); -afterEach(() => { - globalThis.fetch = originalFetch; -}); + afterEach(() => { + globalThis.fetch = originalFetch; + }); -describe("fetchPokemonListGraphQL", () => { it("GraphQLエンドポイントにPOSTリクエストを送信する", async () => { (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, @@ -77,7 +71,7 @@ describe("fetchPokemonListGraphQL", () => { expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json" }, - }), + }) ); }); @@ -114,14 +108,14 @@ describe("fetchPokemonListGraphQL", () => { expect(result.pokemon[0].name).toBe("Bulbasaur"); }); - it("HTTPエラー時にエラーをスローする", async () => { + it("HTTPエラー時にGraphQLエラーをスローする", async () => { (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500, }); await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( - "GraphQL request failed: 500", + "GraphQL request failed: 500" ); }); @@ -135,7 +129,7 @@ describe("fetchPokemonListGraphQL", () => { }); await expect(fetchPokemonListGraphQL(20, 0, "ja")).rejects.toThrow( - "GraphQL error: Field not found", + "GraphQL error: Field not found" ); }); }); diff --git a/__tests__/home/screens/HomeScreen.test.tsx b/__tests__/home/screens/HomeScreen.test.tsx deleted file mode 100644 index 9ad6bf7..0000000 --- a/__tests__/home/screens/HomeScreen.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { HomeScreen } from "@/src/home"; -import type { PokemonSummary } from "@/src/shared"; - -const mockPokemon: PokemonSummary[] = [ - { id: 1, name: "Bulbasaur", types: [] }, - { id: 4, name: "Charmander", types: [] }, - { id: 25, name: "Pikachu", types: [] }, -]; - -const mockUsePokemonList = { - pokemon: mockPokemon, - isLoading: false, - isLoadingMore: false, - isRefreshing: false, - hasMore: true, - error: null as string | null, - loadMore: jest.fn(), - refresh: jest.fn(), -}; - -jest.mock("@/src/home/hooks/usePokemonList", () => ({ - usePokemonList: () => mockUsePokemonList, -})); - -jest.mock("@react-navigation/bottom-tabs", () => ({ - useBottomTabBarHeight: () => 49, -})); - -jest.mock("expo-router", () => ({ - Link: ({ - children, - href, - }: { - children: React.ReactNode; - href: string; - asChild?: boolean; - }) => { - const { View } = require("react-native"); - return {children}; - }, -})); - -const renderWithProvider = () => render(); - -describe("HomeScreen", () => { - beforeEach(() => { - mockUsePokemonList.pokemon = mockPokemon; - mockUsePokemonList.isLoading = false; - mockUsePokemonList.isLoadingMore = false; - mockUsePokemonList.isRefreshing = false; - mockUsePokemonList.hasMore = true; - mockUsePokemonList.error = null; - mockUsePokemonList.loadMore = jest.fn(); - mockUsePokemonList.refresh = jest.fn(); - }); - - it("ポケモンカードが表示される", () => { - renderWithProvider(); - expect(screen.getByText("Pikachu")).toBeTruthy(); - expect(screen.getByText("Bulbasaur")).toBeTruthy(); - }); - - it("各カードが詳細画面へのリンクを持つ", () => { - renderWithProvider(); - expect(screen.getByTestId("link-/detail/25")).toBeTruthy(); - expect(screen.getByTestId("link-/detail/1")).toBeTruthy(); - }); - - it("FABボタンが表示される", () => { - renderWithProvider(); - expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); - }); - - it("FABをタップすると検索入力フィールドが表示される", () => { - renderWithProvider(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - expect(screen.getByTestId("search-input")).toBeTruthy(); - }); - - it("検索テキスト入力でポケモンがフィルタリングされる", () => { - renderWithProvider(); - fireEvent.press(screen.getByTestId("floating-search-fab")); - fireEvent.changeText(screen.getByTestId("search-input"), "Pika"); - expect(screen.getByText("Pikachu")).toBeTruthy(); - expect(screen.queryByText("Bulbasaur")).toBeNull(); - }); - - it("各カードにお気に入りボタンが表示される", () => { - renderWithProvider(); - const buttons = screen.getAllByTestId("favorite-button"); - expect(buttons.length).toBeGreaterThan(0); - }); - - it("ローディング中にActivityIndicatorが表示される", () => { - mockUsePokemonList.isLoading = true; - renderWithProvider(); - expect(screen.getByTestId("loading-indicator")).toBeTruthy(); - }); - - it("エラー時にエラーメッセージが表示される", () => { - mockUsePokemonList.isLoading = false; - mockUsePokemonList.error = "Network error"; - mockUsePokemonList.pokemon = []; - renderWithProvider(); - expect(screen.getByTestId("error-text")).toBeTruthy(); - }); -}); diff --git a/__tests__/home/screens/homeScreen.feature b/__tests__/home/screens/homeScreen.feature new file mode 100644 index 0000000..3034aa3 --- /dev/null +++ b/__tests__/home/screens/homeScreen.feature @@ -0,0 +1,140 @@ +Feature: ホーム画面 + ホーム画面のUI表示とコンポーネントのテスト + + # --- ポケモンカード --- + + Scenario: ポケモンの名前が表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "ピカチュウ" が表示される + + Scenario: ポケモンの画像が正しいURLで表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then 画像URLが "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" である + + Scenario: タイプバッジが翻訳されて表示される + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then "types.electric" が表示される + + Scenario: 複数タイプの場合、全てのバッジが翻訳されて表示される + Given リザードンのデータが用意されている + When PokemonCardを描画する + Then "types.fire" が表示される + And "types.flying" が表示される + + Scenario: onPressコールバックが呼ばれる + Given ピカチュウのデータが用意されている + When onPress付きでPokemonCardを描画する + And カードを押す + Then onPressが1回呼ばれる + + Scenario: onPressが未指定の場合でもエラーにならない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then カードを押してもエラーにならない + + Scenario: isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + Then お気に入りボタンが表示される + + Scenario: isFavoriteがtrueの場合、Lottieアニメーションが表示される + Given ピカチュウのデータが用意されている + When お気に入り状態でPokemonCardを描画する + Then Lottieアニメーションが表示される + + Scenario: お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる + Given ピカチュウのデータが用意されている + When お気に入り機能付きでPokemonCardを描画する + And お気に入りボタンを押す + Then onToggleFavoriteが1回呼ばれる + + Scenario: isFavoriteが未指定の場合、お気に入りボタンが表示されない + Given ピカチュウのデータが用意されている + When PokemonCardを描画する + Then お気に入りボタンが表示されない + + # --- フローティング検索ボタン --- + + Scenario: FloatingSearchButtonのFABボタンが表示される + Given FloatingSearchButtonがレンダリングされている + Then FABボタンが表示される + + Scenario: FABをタップすると検索入力が表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索入力にテキストを入力するとonChangeTextが呼ばれる + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 検索入力に "Pika" と入力する + Then onChangeTextが "Pika" で呼ばれる + + Scenario: 閉じるボタンをタップすると折りたたまれテキストがクリアされる + Given 検索テキスト "Pika" でFloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And 閉じるボタンをタップする + Then onChangeTextが "" で呼ばれる + + Scenario: プレースホルダーが表示される + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + Then プレースホルダー "Search..." が表示される + + Scenario: キーボードが閉じたらFABボタンに戻る + Given FloatingSearchButtonがレンダリングされている + When FABボタンをタップする + And キーボードが閉じられる + Then onChangeTextが "" で呼ばれる + And FABボタンが表示される + + # --- ホーム画面統合 --- + + Scenario: ホーム画面にポケモンカードが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then "Pikachu" が表示される + And "Bulbasaur" が表示される + + Scenario: 各カードが詳細画面へのリンクを持つ + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then ID 25 の詳細リンクが存在する + And ID 1 の詳細リンクが存在する + + Scenario: ホーム画面にFABボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then FABボタンが表示される + + Scenario: ホーム画面でFABをタップすると検索入力フィールドが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + Then 検索入力フィールドが表示される + + Scenario: 検索テキスト入力でポケモンがフィルタリングされる + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + And FABボタンをタップする + And 検索入力に "Pika" と入力する + Then "Pikachu" が表示される + And "Bulbasaur" は表示されない + + Scenario: 各カードにお気に入りボタンが表示される + Given ポケモンリストが正常にロードされている + When ホーム画面をレンダリングする + Then お気に入りボタンが表示される + + Scenario: ローディング中にActivityIndicatorが表示される + Given ポケモンリストがローディング中である + When ホーム画面をレンダリングする + Then ローディングインジケーターが表示される + + Scenario: エラー時にエラーメッセージが表示される + Given ポケモンリストの取得でエラーが発生している + When ホーム画面をレンダリングする + Then エラーメッセージが表示される diff --git a/__tests__/home/screens/homeScreen.steps.tsx b/__tests__/home/screens/homeScreen.steps.tsx new file mode 100644 index 0000000..0efeba6 --- /dev/null +++ b/__tests__/home/screens/homeScreen.steps.tsx @@ -0,0 +1,619 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import { + render, + screen, + fireEvent, +} from "@testing-library/react-native"; +import { Keyboard, Platform } from "react-native"; +import { + FloatingSearchButton, + HomeScreen, +} from "@/src/home"; +import { PokemonCard } from "@/src/shared"; +import type { PokemonSummary } from "@/src/shared"; + +// --- jest.mock (トップレベル) --- + +const mockHomeScreenPokemon: PokemonSummary[] = [ + { id: 1, name: "Bulbasaur", types: [] }, + { id: 4, name: "Charmander", types: [] }, + { id: 25, name: "Pikachu", types: [] }, +]; + +const mockUsePokemonList = { + pokemon: mockHomeScreenPokemon, + isLoading: false, + isLoadingMore: false, + isRefreshing: false, + hasMore: true, + error: null as string | null, + loadMore: jest.fn(), + refresh: jest.fn(), +}; + +jest.mock("@/src/home/hooks/usePokemonList", () => ({ + usePokemonList: () => mockUsePokemonList, +})); + +jest.mock("@/src/home/repository/pokemonGraphqlApi"); + +jest.mock("@react-navigation/bottom-tabs", () => ({ + useBottomTabBarHeight: () => 49, +})); + +jest.mock("expo-router", () => ({ + Link: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + asChild?: boolean; + }) => { + const { View } = require("react-native"); + return {children}; + }, +})); + +// --- テストデータ --- + +const mockCardPokemon: PokemonSummary = { + id: 25, + name: "ピカチュウ", + types: ["electric"], +}; + +const mockMultiTypePokemon: PokemonSummary = { + id: 6, + name: "リザードン", + types: ["fire", "flying"], +}; + +const resetMockState = () => { + mockUsePokemonList.pokemon = mockHomeScreenPokemon; + mockUsePokemonList.isLoading = false; + mockUsePokemonList.isLoadingMore = false; + mockUsePokemonList.isRefreshing = false; + mockUsePokemonList.hasMore = true; + mockUsePokemonList.error = null; + mockUsePokemonList.loadMore = jest.fn(); + mockUsePokemonList.refresh = jest.fn(); +}; + +// --- Feature定義 --- + +const feature = loadFeature("__tests__/home/screens/homeScreen.feature"); + +defineFeature(feature, (test) => { + // --- ポケモンカード --- + + let cardPokemon: PokemonSummary; + let cardOnPress: jest.Mock; + let cardOnToggleFavorite: jest.Mock; + + beforeEach(() => { + resetMockState(); + }); + + test("ポケモンの名前が表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("ポケモンの画像が正しいURLで表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^画像URLが "(.*)" である$/, (url: string) => { + const image = screen.getByTestId("pokemon-image"); + expect(image.props.source.uri).toBe(url); + }); + }); + + test("タイプバッジが翻訳されて表示される", ({ given, when, then }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("複数タイプの場合、全てのバッジが翻訳されて表示される", ({ + given, + when, + then, + and, + }) => { + given("リザードンのデータが用意されている", () => { + cardPokemon = mockMultiTypePokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (text: string) => { + expect(screen.getByText(text)).toBeTruthy(); + }); + }); + + test("onPressコールバックが呼ばれる", ({ given, when, then, and }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("onPress付きでPokemonCardを描画する", () => { + cardOnPress = jest.fn(); + render(); + }); + + and("カードを押す", () => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }); + + then("onPressが1回呼ばれる", () => { + expect(cardOnPress).toHaveBeenCalledTimes(1); + }); + }); + + test("onPressが未指定の場合でもエラーにならない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("カードを押してもエラーにならない", () => { + expect(() => { + fireEvent.press(screen.getByTestId("pokemon-card")); + }).not.toThrow(); + }); + }); + + test("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + then("お気に入りボタンが表示される", () => { + expect(screen.getByTestId("favorite-button")).toBeTruthy(); + }); + }); + + test("isFavoriteがtrueの場合、Lottieアニメーションが表示される", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り状態でPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + then("Lottieアニメーションが表示される", () => { + expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); + }); + }); + + test("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("お気に入り機能付きでPokemonCardを描画する", () => { + cardOnToggleFavorite = jest.fn(); + render( + + ); + }); + + and("お気に入りボタンを押す", () => { + fireEvent.press(screen.getByTestId("favorite-button")); + }); + + then("onToggleFavoriteが1回呼ばれる", () => { + expect(cardOnToggleFavorite).toHaveBeenCalledTimes(1); + }); + }); + + test("isFavoriteが未指定の場合、お気に入りボタンが表示されない", ({ + given, + when, + then, + }) => { + given("ピカチュウのデータが用意されている", () => { + cardPokemon = mockCardPokemon; + }); + + when("PokemonCardを描画する", () => { + render(); + }); + + then("お気に入りボタンが表示されない", () => { + expect(screen.queryByTestId("favorite-button")).toBeNull(); + }); + }); + + // --- フローティング検索ボタン --- + + let fabOnChangeText: jest.Mock; + + const defaultFabProps = { + searchText: "", + onChangeText: jest.fn(), + placeholder: "Search...", + }; + + test("FloatingSearchButtonのFABボタンが表示される", ({ given, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("FABをタップすると検索入力が表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索入力にテキストを入力するとonChangeTextが呼ばれる", ({ + given, + when, + then, + and, + }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + fabOnChangeText = jest.fn(); + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("閉じるボタンをタップすると折りたたまれテキストがクリアされる", ({ + given, + when, + then, + and, + }) => { + given( + /^検索テキスト "(.*)" でFloatingSearchButtonがレンダリングされている$/, + (searchText: string) => { + fabOnChangeText = jest.fn(); + render( + + ); + } + ); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and("閉じるボタンをタップする", () => { + fireEvent.press(screen.getByTestId("search-close-button")); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + }); + + test("プレースホルダーが表示される", ({ given, when, then }) => { + given("FloatingSearchButtonがレンダリングされている", () => { + render(); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then(/^プレースホルダー "(.*)" が表示される$/, (placeholder: string) => { + expect(screen.getByPlaceholderText(placeholder)).toBeTruthy(); + }); + }); + + test("キーボードが閉じたらFABボタンに戻る", ({ + given, + when, + then, + and, + }) => { + let hideCallback: (() => void) | undefined; + let addListenerSpy: jest.SpyInstance; + + given("FloatingSearchButtonがレンダリングされている", () => { + fabOnChangeText = jest.fn(); + const hideEvent = + Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; + + addListenerSpy = jest.spyOn(Keyboard, "addListener"); + addListenerSpy.mockImplementation((event, callback) => { + if (event === hideEvent) { + hideCallback = callback as () => void; + } + return { remove: jest.fn() } as unknown as ReturnType< + typeof Keyboard.addListener + >; + }); + + render( + + ); + }); + + when("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + + and("キーボードが閉じられる", () => { + const { act } = require("@testing-library/react-native"); + act(() => { + hideCallback?.(); + }); + }); + + then(/^onChangeTextが "(.*)" で呼ばれる$/, (expected: string) => { + expect(fabOnChangeText).toHaveBeenCalledWith(expected); + }); + + and("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + addListenerSpy.mockRestore(); + }); + }); + + // --- ホーム画面統合 --- + + test("ホーム画面にポケモンカードが表示される", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state is already set + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + }); + + test("各カードが詳細画面へのリンクを持つ", ({ given, when, then, and }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + + and(/^ID (\d+) の詳細リンクが存在する$/, (id: string) => { + expect(screen.getByTestId(`link-/detail/${id}`)).toBeTruthy(); + }); + }); + + test("ホーム画面にFABボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("FABボタンが表示される", () => { + expect(screen.getByTestId("floating-search-fab")).toBeTruthy(); + }); + }); + + test("ホーム画面でFABをタップすると検索入力フィールドが表示される", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + then("検索入力フィールドが表示される", () => { + expect(screen.getByTestId("search-input")).toBeTruthy(); + }); + }); + + test("検索テキスト入力でポケモンがフィルタリングされる", ({ + given, + when, + then, + and, + }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + and("FABボタンをタップする", () => { + fireEvent.press(screen.getByTestId("floating-search-fab")); + }); + + and(/^検索入力に "(.*)" と入力する$/, (text: string) => { + fireEvent.changeText(screen.getByTestId("search-input"), text); + }); + + then(/^"(.*)" が表示される$/, (name: string) => { + expect(screen.getByText(name)).toBeTruthy(); + }); + + and(/^"(.*)" は表示されない$/, (name: string) => { + expect(screen.queryByText(name)).toBeNull(); + }); + }); + + test("各カードにお気に入りボタンが表示される", ({ given, when, then }) => { + given("ポケモンリストが正常にロードされている", () => { + // Default mock state + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("お気に入りボタンが表示される", () => { + const buttons = screen.getAllByTestId("favorite-button"); + expect(buttons.length).toBeGreaterThan(0); + }); + }); + + test("ローディング中にActivityIndicatorが表示される", ({ + given, + when, + then, + }) => { + given("ポケモンリストがローディング中である", () => { + mockUsePokemonList.isLoading = true; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("ローディングインジケーターが表示される", () => { + expect(screen.getByTestId("loading-indicator")).toBeTruthy(); + }); + }); + + test("エラー時にエラーメッセージが表示される", ({ given, when, then }) => { + given("ポケモンリストの取得でエラーが発生している", () => { + mockUsePokemonList.isLoading = false; + mockUsePokemonList.error = "Network error"; + mockUsePokemonList.pokemon = []; + }); + + when("ホーム画面をレンダリングする", () => { + render(); + }); + + then("エラーメッセージが表示される", () => { + expect(screen.getByTestId("error-text")).toBeTruthy(); + }); + }); +}); diff --git a/__tests__/settings/components/LanguagePicker.test.tsx b/__tests__/settings/components/LanguagePicker.test.tsx deleted file mode 100644 index 1bb9e06..0000000 --- a/__tests__/settings/components/LanguagePicker.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { LanguagePicker } from "@/src/settings/components/LanguagePicker"; - -const mockChangeLanguage = jest.fn(); -let mockLanguage = "ja"; - -jest.mock("@/src/shared", () => ({ - useLanguage: () => ({ - language: mockLanguage, - changeLanguage: mockChangeLanguage, - }), -})); - -describe("LanguagePicker", () => { - beforeEach(() => { - mockLanguage = "ja"; - mockChangeLanguage.mockReset(); - }); - - it("日本語と英語の選択肢が表示される", () => { - render(); - - expect(screen.getByTestId("language-option-ja")).toBeTruthy(); - expect(screen.getByTestId("language-option-en")).toBeTruthy(); - }); - - it("現在の言語にチェックマークが表示される", () => { - render(); - - expect(screen.getByTestId("checkmark-ja")).toBeTruthy(); - expect(screen.queryByTestId("checkmark-en")).toBeNull(); - }); - - it("言語を選択するとchangeLanguageが呼ばれる", () => { - render(); - - fireEvent.press(screen.getByTestId("language-option-en")); - - expect(mockChangeLanguage).toHaveBeenCalledWith("en"); - }); -}); diff --git a/__tests__/settings/screens/settingsScreen.feature b/__tests__/settings/screens/settingsScreen.feature new file mode 100644 index 0000000..3818c5a --- /dev/null +++ b/__tests__/settings/screens/settingsScreen.feature @@ -0,0 +1,18 @@ +Feature: 設定画面 + + Scenario: 日本語と英語の選択肢が表示される + Given 現在の言語が "ja" である + When 言語選択コンポーネントを表示する + Then 日本語の選択肢が表示される + And 英語の選択肢が表示される + + Scenario: 現在の言語にチェックマークが表示される + Given 現在の言語が "ja" である + When 言語選択コンポーネントを表示する + Then 日本語にチェックマークが表示される + And 英語にチェックマークが表示されない + + Scenario: 言語を選択するとchangeLanguageが呼ばれる + Given 現在の言語が "ja" である + When 英語の選択肢をタップする + Then changeLanguageが "en" で呼ばれる diff --git a/__tests__/settings/screens/settingsScreen.steps.tsx b/__tests__/settings/screens/settingsScreen.steps.tsx new file mode 100644 index 0000000..4b5b4aa --- /dev/null +++ b/__tests__/settings/screens/settingsScreen.steps.tsx @@ -0,0 +1,72 @@ +jest.unmock("react-i18next"); + +import { defineFeature, loadFeature } from "jest-cucumber"; +import { render, screen, fireEvent } from "@testing-library/react-native"; +import { LanguagePicker } from "@/src/settings/components/LanguagePicker"; +import i18n, { initI18n } from "@/src/shared/i18n/i18n"; + +const feature = loadFeature( + "__tests__/settings/screens/settingsScreen.feature" +); + +defineFeature(feature, (test) => { + beforeEach(async () => { + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } + }); + + test("日本語と英語の選択肢が表示される", ({ given, when, then, and }) => { + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); + }); + + when("言語選択コンポーネントを表示する", () => { + render(); + }); + + then("日本語の選択肢が表示される", () => { + expect(screen.getByTestId("language-option-ja")).toBeTruthy(); + }); + + and("英語の選択肢が表示される", () => { + expect(screen.getByTestId("language-option-en")).toBeTruthy(); + }); + }); + + test("現在の言語にチェックマークが表示される", ({ given, when, then, and }) => { + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); + }); + + when("言語選択コンポーネントを表示する", () => { + render(); + }); + + then("日本語にチェックマークが表示される", () => { + expect(screen.getByTestId("checkmark-ja")).toBeTruthy(); + }); + + and("英語にチェックマークが表示されない", () => { + expect(screen.queryByTestId("checkmark-en")).toBeNull(); + }); + }); + + test("言語を選択するとchangeLanguageが呼ばれる", ({ given, when, then }) => { + given(/^現在の言語が "ja" である$/, async () => { + await initI18n(); + await i18n.changeLanguage("ja"); + }); + + when("英語の選択肢をタップする", () => { + render(); + fireEvent.press(screen.getByTestId("language-option-en")); + }); + + then(/^changeLanguageが "en" で呼ばれる$/, () => { + expect(i18n.language).toBe("en"); + }); + }); +}); diff --git a/__tests__/shared/components/FavoriteButton.test.tsx b/__tests__/shared/components/FavoriteButton.test.tsx deleted file mode 100644 index fab3cae..0000000 --- a/__tests__/shared/components/FavoriteButton.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { FavoriteButton } from "@/src/shared"; - -describe("FavoriteButton", () => { - it("Lottieアニメーションコンポーネントが描画される", () => { - render(); - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - - it("autoPlayが無効になっている", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.autoPlay).toBe(false); - }); - - it("ループが無効になっている", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.loop).toBe(false); - }); - - it("ボタン押下時にonToggleが即座に呼ばれる", () => { - const onToggle = jest.fn(); - render(); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggle).toHaveBeenCalledTimes(1); - }); - - it("お気に入り状態の場合、ONの最終フレームで初期表示される", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBeCloseTo(90 / 181); - }); - - it("非お気に入り状態の場合、progressが0で初期表示される", () => { - render(); - const lottie = screen.getByTestId("favorite-lottie"); - expect(lottie.props.progress).toBe(0); - }); - - it("外部からisFavoriteが変更された場合、progressが再同期される", () => { - const { rerender } = render( - , - ); - expect(screen.getByTestId("favorite-lottie").props.progress).toBe(0); - rerender(); - expect(screen.getByTestId("favorite-lottie").props.progress).toBeCloseTo( - 90 / 181, - ); - }); - - it("お気に入り状態のアクセシビリティラベルが正しい", () => { - render(); - expect(screen.getByLabelText("favoriteButton.remove")).toBeTruthy(); - }); - - it("非お気に入り状態のアクセシビリティラベルが正しい", () => { - render(); - expect(screen.getByLabelText("favoriteButton.add")).toBeTruthy(); - }); -}); diff --git a/__tests__/shared/components/PokemonCard.test.tsx b/__tests__/shared/components/PokemonCard.test.tsx deleted file mode 100644 index 9a11d2d..0000000 --- a/__tests__/shared/components/PokemonCard.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react-native"; -import { PokemonCard } from "@/src/shared"; -import type { PokemonSummary } from "@/src/shared"; - -const mockPokemon: PokemonSummary = { - id: 25, - name: "ピカチュウ", - types: ["electric"], -}; - -const mockMultiTypePokemon: PokemonSummary = { - id: 6, - name: "リザードン", - types: ["fire", "flying"], -}; - -describe("PokemonCard", () => { - it("ポケモンの名前が表示される", () => { - render(); - expect(screen.getByText("ピカチュウ")).toBeTruthy(); - }); - - it("ポケモンの画像が正しいURLで表示される", () => { - render(); - const image = screen.getByTestId("pokemon-image"); - expect(image.props.source.uri).toBe( - "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png" - ); - }); - - it("タイプバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.electric")).toBeTruthy(); - }); - - it("複数タイプの場合、全てのバッジが翻訳されて表示される", () => { - render(); - expect(screen.getByText("types.fire")).toBeTruthy(); - expect(screen.getByText("types.flying")).toBeTruthy(); - }); - - it("onPressコールバックが呼ばれる", () => { - const onPress = jest.fn(); - render(); - fireEvent.press(screen.getByTestId("pokemon-card")); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it("onPressが未指定の場合でもエラーにならない", () => { - render(); - expect(() => { - fireEvent.press(screen.getByTestId("pokemon-card")); - }).not.toThrow(); - }); - - it("isFavoriteとonToggleFavoriteが渡された場合、お気に入りボタンが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-button")).toBeTruthy(); - }); - - it("isFavoriteがtrueの場合、Lottieアニメーションが表示される", () => { - render( - , - ); - expect(screen.getByTestId("favorite-lottie")).toBeTruthy(); - }); - - it("お気に入りボタン押下後アニメーション完了でonToggleFavoriteが呼ばれる", () => { - const onToggleFavorite = jest.fn(); - render( - , - ); - fireEvent.press(screen.getByTestId("favorite-button")); - expect(onToggleFavorite).toHaveBeenCalledTimes(1); - }); - - it("isFavoriteが未指定の場合、お気に入りボタンが表示されない", () => { - render(); - expect(screen.queryByTestId("favorite-button")).toBeNull(); - }); -}); diff --git a/__tests__/shared/i18n/i18n.test.ts b/__tests__/shared/i18n/i18n.test.ts index cd9b51d..3ac5dc9 100644 --- a/__tests__/shared/i18n/i18n.test.ts +++ b/__tests__/shared/i18n/i18n.test.ts @@ -11,51 +11,44 @@ describe("i18n", () => { } }); - describe("initI18n", () => { - it("デフォルト言語が日本語で初期化される", async () => { - await initI18n(); - expect(i18n.language).toBe("ja"); - }); - - it("AsyncStorageに保存された言語で初期化される", async () => { - await AsyncStorage.setItem(STORAGE_KEY, "en"); - await initI18n(); - expect(i18n.language).toBe("en"); - }); - - it("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", async () => { - await AsyncStorage.setItem(STORAGE_KEY, "fr"); - await initI18n(); - expect(i18n.language).toBe("ja"); - }); - }); - - describe("翻訳", () => { - beforeEach(async () => { - await initI18n(); - }); - - it("日本語の翻訳キーが正しく解決される", () => { - expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); - expect(i18n.t("favorites.empty")).toBe("お気に入りのポケモンはまだいません"); - }); - - it("英語に切り替えると英語の翻訳が返される", async () => { - await i18n.changeLanguage("en"); - expect(i18n.t("tabs.pokedex")).toBe("Pokédex"); - expect(i18n.t("favorites.empty")).toBe("No favorite Pokémon yet"); - }); - - it("日本語に戻すと日本語の翻訳が返される", async () => { - await i18n.changeLanguage("en"); - await i18n.changeLanguage("ja"); - expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); - }); + it("デフォルト言語が日本語で初期化される", async () => { + await initI18n(); + expect(i18n.language).toBe("ja"); + }); + + it("AsyncStorageに保存された言語で初期化される", async () => { + await AsyncStorage.setItem(STORAGE_KEY, "en"); + await initI18n(); + expect(i18n.language).toBe("en"); + }); + + it("不正な言語が保存されていた場合はデフォルトの日本語で初期化される", async () => { + await AsyncStorage.setItem(STORAGE_KEY, "fr"); + await initI18n(); + expect(i18n.language).toBe("ja"); + }); + + it("日本語の翻訳キーが正しく解決される", async () => { + await initI18n(); + expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); + expect(i18n.t("favorites.empty")).toBe("お気に入りのポケモンはまだいません"); + }); + + it("英語に切り替えると英語の翻訳が返される", async () => { + await initI18n(); + await i18n.changeLanguage("en"); + expect(i18n.t("tabs.pokedex")).toBe("Pokédex"); + expect(i18n.t("favorites.empty")).toBe("No favorite Pokémon yet"); + }); + + it("日本語に戻すと日本語の翻訳が返される", async () => { + await initI18n(); + await i18n.changeLanguage("en"); + await i18n.changeLanguage("ja"); + expect(i18n.t("tabs.pokedex")).toBe("ポケモン図鑑"); }); - describe("SUPPORTED_LANGUAGES", () => { - it("日本語と英語が含まれる", () => { - expect(SUPPORTED_LANGUAGES).toEqual(["ja", "en"]); - }); + it("SUPPORTED_LANGUAGESに日本語と英語が含まれる", () => { + expect(SUPPORTED_LANGUAGES).toEqual(["ja", "en"]); }); }); diff --git a/__tests__/shared/i18n/useLanguage.test.ts b/__tests__/shared/i18n/useLanguage.test.ts index 7ae3c34..740291e 100644 --- a/__tests__/shared/i18n/useLanguage.test.ts +++ b/__tests__/shared/i18n/useLanguage.test.ts @@ -2,37 +2,39 @@ jest.unmock("react-i18next"); import { renderHook, act } from "@testing-library/react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import i18n, { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; import { useLanguage } from "@/src/shared/i18n/useLanguage"; -import { initI18n, STORAGE_KEY } from "@/src/shared/i18n/i18n"; +import type { SupportedLanguage } from "@/src/shared"; describe("useLanguage", () => { beforeEach(async () => { await AsyncStorage.clear(); - await initI18n(); + if (i18n.isInitialized) { + await i18n.changeLanguage("ja"); + } }); - it("現在の言語を返す", () => { + it("現在の言語を返す", async () => { + await initI18n(); const { result } = renderHook(() => useLanguage()); expect(result.current.language).toBe("ja"); }); it("言語を変更するとi18nextの言語が更新される", async () => { + await initI18n(); const { result } = renderHook(() => useLanguage()); - await act(async () => { - await result.current.changeLanguage("en"); + await result.current.changeLanguage("en" as SupportedLanguage); }); - expect(result.current.language).toBe("en"); }); it("言語変更がAsyncStorageに保存される", async () => { + await initI18n(); const { result } = renderHook(() => useLanguage()); - await act(async () => { - await result.current.changeLanguage("en"); + await result.current.changeLanguage("en" as SupportedLanguage); }); - const saved = await AsyncStorage.getItem(STORAGE_KEY); expect(saved).toBe("en"); }); diff --git a/__tests__/shared/repository/pokemonApi.test.ts b/__tests__/shared/repository/pokemonApi.test.ts index 5a2ac8b..f4d1689 100644 --- a/__tests__/shared/repository/pokemonApi.test.ts +++ b/__tests__/shared/repository/pokemonApi.test.ts @@ -1,10 +1,13 @@ -import { fetchPokemonById } from "@/src/shared/repository/pokemonApi"; +import { fetchPokemonById } from "@/src/shared"; -const mockApiResponse = { +const mockPokemonApiResponse = { id: 25, name: "pikachu", types: [ - { slot: 1, type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" } }, + { + slot: 1, + type: { name: "electric", url: "https://pokeapi.co/api/v2/type/13/" }, + }, ], }; @@ -12,43 +15,39 @@ const mockMultiTypeResponse = { id: 1, name: "bulbasaur", types: [ - { slot: 1, type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" } }, - { slot: 2, type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" } }, + { + slot: 1, + type: { name: "grass", url: "https://pokeapi.co/api/v2/type/12/" }, + }, + { + slot: 2, + type: { name: "poison", url: "https://pokeapi.co/api/v2/type/4/" }, + }, ], }; const originalFetch = globalThis.fetch; -beforeEach(() => { - globalThis.fetch = jest.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); +describe("pokemonApi", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); -describe("fetchPokemonById", () => { it("正しいURLでfetchを呼び出す", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockPokemonApiResponse), }); - await fetchPokemonById(25); - - expect(globalThis.fetch).toHaveBeenCalledWith( - "https://pokeapi.co/api/v2/pokemon/25" - ); + expect(globalThis.fetch).toHaveBeenCalledWith("https://pokeapi.co/api/v2/pokemon/25"); }); it("レスポンスをPokemon型に変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockApiResponse), + json: () => Promise.resolve(mockPokemonApiResponse), }); - const result = await fetchPokemonById(25); - expect(result).toEqual({ id: 25, name: "Pikachu", @@ -57,13 +56,11 @@ describe("fetchPokemonById", () => { }); it("複数タイプを正しく変換する", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockMultiTypeResponse), }); - const result = await fetchPokemonById(1); - expect(result).toEqual({ id: 1, name: "Bulbasaur", @@ -72,24 +69,19 @@ describe("fetchPokemonById", () => { }); it("空文字の名前が正しく処理される", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 1, name: "", types: [] }), }); - const result = await fetchPokemonById(1); - expect(result.name).toBe(""); }); it("HTTPエラー時にエラーをスローする", async () => { - (globalThis.fetch as jest.Mock).mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: false, status: 404, }); - - await expect(fetchPokemonById(99999)).rejects.toThrow( - "Failed to fetch pokemon: 404" - ); + await expect(fetchPokemonById(99999)).rejects.toThrow("Failed to fetch pokemon: 404"); }); }); diff --git a/__tests__/shared/stores/useFavoritesStore.test.tsx b/__tests__/shared/stores/useFavoritesStore.test.tsx index 3208dc1..1bdf2a7 100644 --- a/__tests__/shared/stores/useFavoritesStore.test.tsx +++ b/__tests__/shared/stores/useFavoritesStore.test.tsx @@ -1,7 +1,6 @@ import { renderHook, act } from "@testing-library/react-native"; import { Alert } from "react-native"; -import { useFavorites } from "@/src/shared"; -import { useFavoritesStore } from "@/src/shared/stores/useFavoritesStore"; +import { useFavorites, useFavoritesStore } from "@/src/shared"; jest.mock("@/src/shared/i18n", () => ({ i18n: { @@ -17,98 +16,92 @@ describe("useFavoritesStore", () => { (Alert.alert as jest.Mock).mockClear(); }); - describe("useFavorites", () => { - it("初期状態ではお気に入りが空である", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.favoriteIds).toEqual([]); - }); + it("初期状態ではお気に入りが空である", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.favoriteIds).toEqual([]); + }); - it("toggleFavoriteでポケモンをお気に入りに追加できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.favoriteIds).toEqual([25]); + it("toggleFavoriteでポケモンをお気に入りに追加できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); }); + expect(result.current.favoriteIds).toEqual([25]); + }); - it("toggleFavoriteで既にお気に入りのポケモンを削除できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.favoriteIds).toEqual([]); + it("toggleFavoriteで既にお気に入りのポケモンを削除できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); }); - - it("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(25); - }); - expect(result.current.isFavorite(25)).toBe(true); + act(() => { + result.current.toggleFavorite(25); }); + expect(result.current.favoriteIds).toEqual([]); + }); - it("isFavoriteが未登録のポケモンに対してfalseを返す", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.isFavorite(25)).toBe(false); + it("isFavoriteがお気に入り登録済みのポケモンに対してtrueを返す", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(25); }); + expect(result.current.isFavorite(25)).toBe(true); + }); - it("お気に入りが6匹に達している場合、追加できずアラートが表示される", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - expect(result.current.favoriteIds).toHaveLength(6); + it("isFavoriteが未登録のポケモンに対してfalseを返す", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.isFavorite(25)).toBe(false); + }); - act(() => { - result.current.toggleFavorite(7); - }); - expect(result.current.favoriteIds).toHaveLength(6); - expect(result.current.favoriteIds).not.toContain(7); - expect(Alert.alert).toHaveBeenCalledWith( - "favorites.limitTitle", - "favorites.limitMessage" - ); + it("お気に入りが6匹に達している場合、追加できずアラートが表示される", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); }); - - it("上限に達していても既存のお気に入りは削除できる", () => { - const { result } = renderHook(() => useFavorites()); - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - - act(() => { - result.current.toggleFavorite(3); - }); - expect(result.current.favoriteIds).toHaveLength(5); - expect(result.current.favoriteIds).not.toContain(3); + act(() => { + result.current.toggleFavorite(7); }); + expect(result.current.favoriteIds).toHaveLength(6); + expect(result.current.favoriteIds).not.toContain(7); + expect(Alert.alert).toHaveBeenCalledWith( + "favorites.limitTitle", + "favorites.limitMessage" + ); + }); - it("isFullが上限到達時にtrueを返す", () => { - const { result } = renderHook(() => useFavorites()); - expect(result.current.isFull).toBe(false); + it("上限に達していても既存のお気に入りは削除できる", () => { + const { result } = renderHook(() => useFavorites()); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); + }); + act(() => { + result.current.toggleFavorite(3); + }); + expect(result.current.favoriteIds).toHaveLength(5); + expect(result.current.favoriteIds).not.toContain(3); + }); - act(() => { - result.current.toggleFavorite(1); - result.current.toggleFavorite(2); - result.current.toggleFavorite(3); - result.current.toggleFavorite(4); - result.current.toggleFavorite(5); - result.current.toggleFavorite(6); - }); - expect(result.current.isFull).toBe(true); + it("isFullが上限到達時にtrueを返す", () => { + const { result } = renderHook(() => useFavorites()); + expect(result.current.isFull).toBe(false); + act(() => { + result.current.toggleFavorite(1); + result.current.toggleFavorite(2); + result.current.toggleFavorite(3); + result.current.toggleFavorite(4); + result.current.toggleFavorite(5); + result.current.toggleFavorite(6); }); + expect(result.current.isFull).toBe(true); }); }); diff --git a/__tests__/splash/components/AnimatedSplash.test.tsx b/__tests__/splash/components/AnimatedSplash.test.tsx deleted file mode 100644 index f268caf..0000000 --- a/__tests__/splash/components/AnimatedSplash.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { Text } from "react-native"; -import { render, act } from "@testing-library/react-native"; -import { AnimatedSplash } from "@/src/splash/components/AnimatedSplash"; - -let mockOnFinish: (() => void) | undefined; -jest.mock("@/src/splash/hooks/useAnimatedSplash", () => ({ - useAnimatedSplash: ({ onFinish }: { onFinish: () => void }) => { - mockOnFinish = onFinish; - return { animatedStyle: { opacity: 1, transform: [{ scale: 1 }] } }; - }, -})); - -describe("AnimatedSplash", () => { - beforeEach(() => { - mockOnFinish = undefined; - }); - - it("スプラッシュ画像が表示される", () => { - const { getByTestId } = render( - - Main - , - ); - - expect(getByTestId("splash-image")).toBeTruthy(); - }); - - it("スプラッシュコンテナがフルスクリーンで表示される", () => { - const { getByTestId } = render( - - Main - , - ); - - expect(getByTestId("animated-splash-container")).toBeTruthy(); - }); - - it("子要素が表示される", () => { - const { getByText } = render( - - Main Content - , - ); - - expect(getByText("Main Content")).toBeTruthy(); - }); - - it("アニメーション完了後にスプラッシュオーバーレイが非表示になる", () => { - const { queryByTestId } = render( - - Main - , - ); - - expect(queryByTestId("animated-splash-container")).toBeTruthy(); - - act(() => { - mockOnFinish?.(); - }); - - expect(queryByTestId("animated-splash-container")).toBeNull(); - }); -}); diff --git a/__tests__/splash/hooks/useAnimatedSplash.test.ts b/__tests__/splash/hooks/useAnimatedSplash.test.ts index f2662d7..b7b8a63 100644 --- a/__tests__/splash/hooks/useAnimatedSplash.test.ts +++ b/__tests__/splash/hooks/useAnimatedSplash.test.ts @@ -1,67 +1,81 @@ import { renderHook, act } from "@testing-library/react-native"; import * as SplashScreen from "expo-splash-screen"; -import { useAnimatedSplash } from "@/src/splash/hooks/useAnimatedSplash"; -describe("useAnimatedSplash", () => { - beforeEach(() => { - jest.useFakeTimers(); - jest.clearAllMocks(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); +// useAnimatedSplashはバレルにエクスポートされていないため直接require +function getRealUseAnimatedSplash() { + const { useAnimatedSplash } = jest.requireActual< + typeof import("@/src/splash/hooks/useAnimatedSplash") + >("@/src/splash/hooks/useAnimatedSplash"); + return useAnimatedSplash; +} +describe("useAnimatedSplash", () => { it("マウント時にSplashScreen.hideAsyncが呼ばれる", () => { const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); renderHook(() => useAnimatedSplash({ onFinish })); expect(SplashScreen.hideAsync).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); it("delay前にはonFinishが呼ばれない", () => { const onFinish = jest.fn(); - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + jest.useFakeTimers(); + const useAnimatedSplash = getRealUseAnimatedSplash(); + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); act(() => { jest.advanceTimersByTime(500); }); expect(onFinish).not.toHaveBeenCalled(); + jest.useRealTimers(); }); it("delay後にonFinishコールバックが呼ばれる", () => { const onFinish = jest.fn(); - renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); + jest.useFakeTimers(); + const useAnimatedSplash = getRealUseAnimatedSplash(); + renderHook(() => useAnimatedSplash({ onFinish, delay: 800 })); act(() => { jest.advanceTimersByTime(800); }); expect(onFinish).toHaveBeenCalledTimes(1); + jest.useRealTimers(); }); it("アンマウント時にタイマーがクリーンアップされる", () => { const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); const { unmount } = renderHook(() => - useAnimatedSplash({ onFinish, delay: 800 }), + useAnimatedSplash({ onFinish, delay: 800 }) ); - unmount(); - act(() => { jest.advanceTimersByTime(1000); }); expect(onFinish).not.toHaveBeenCalled(); + jest.useRealTimers(); }); it("animatedStyleオブジェクトを返す", () => { const onFinish = jest.fn(); + jest.useFakeTimers(); + + const useAnimatedSplash = getRealUseAnimatedSplash(); const { result } = renderHook(() => useAnimatedSplash({ onFinish })); expect(result.current.animatedStyle).toBeDefined(); expect(result.current.animatedStyle).toHaveProperty("opacity"); expect(result.current.animatedStyle).toHaveProperty("transform"); + jest.useRealTimers(); }); }); diff --git a/__tests__/splash/screens/splashScreen.feature b/__tests__/splash/screens/splashScreen.feature new file mode 100644 index 0000000..a60a14f --- /dev/null +++ b/__tests__/splash/screens/splashScreen.feature @@ -0,0 +1,21 @@ +Feature: スプラッシュ画面 + + Scenario: スプラッシュ画像が表示される + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーする + Then スプラッシュ画像が表示される + + Scenario: スプラッシュコンテナがフルスクリーンで表示される + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーする + Then スプラッシュコンテナが表示される + + Scenario: 子要素が表示される + Given スプラッシュコンポーネントに "Main Content" という子要素がある + When スプラッシュ画面をレンダーする + Then "Main Content" が表示される + + Scenario: アニメーション完了後にスプラッシュオーバーレイが非表示になる + Given スプラッシュコンポーネントに子要素がある + When スプラッシュ画面をレンダーしてアニメーションが完了する + Then スプラッシュオーバーレイが非表示になる diff --git a/__tests__/splash/screens/splashScreen.steps.tsx b/__tests__/splash/screens/splashScreen.steps.tsx new file mode 100644 index 0000000..e1fb301 --- /dev/null +++ b/__tests__/splash/screens/splashScreen.steps.tsx @@ -0,0 +1,114 @@ +import { defineFeature, loadFeature } from "jest-cucumber"; +import React from "react"; +import { Text } from "react-native"; +import { render, act } from "@testing-library/react-native"; +import { AnimatedSplash } from "@/src/splash"; + +let mockOnFinish: (() => void) | undefined; +jest.mock("@/src/splash/hooks/useAnimatedSplash", () => ({ + useAnimatedSplash: jest.fn(({ onFinish }: { onFinish: () => void }) => { + mockOnFinish = onFinish; + return { animatedStyle: { opacity: 1, transform: [{ scale: 1 }] } }; + }), +})); + +const feature = loadFeature( + "__tests__/splash/screens/splashScreen.feature" +); + +defineFeature(feature, (test) => { + beforeEach(() => { + mockOnFinish = undefined; + jest.clearAllMocks(); + }); + + test("スプラッシュ画像が表示される", ({ given, when, then }) => { + let getByTestId: ReturnType["getByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main + + ); + getByTestId = result.getByTestId; + }); + + then("スプラッシュ画像が表示される", () => { + expect(getByTestId("splash-image")).toBeTruthy(); + }); + }); + + test("スプラッシュコンテナがフルスクリーンで表示される", ({ given, when, then }) => { + let getByTestId: ReturnType["getByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main + + ); + getByTestId = result.getByTestId; + }); + + then("スプラッシュコンテナが表示される", () => { + expect(getByTestId("animated-splash-container")).toBeTruthy(); + }); + }); + + test("子要素が表示される", ({ given, when, then }) => { + let getByText: ReturnType["getByText"]; + + given(/^スプラッシュコンポーネントに "Main Content" という子要素がある$/, () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーする", () => { + const result = render( + + Main Content + + ); + getByText = result.getByText; + }); + + then(/^"Main Content" が表示される$/, () => { + expect(getByText("Main Content")).toBeTruthy(); + }); + }); + + test("アニメーション完了後にスプラッシュオーバーレイが非表示になる", ({ given, when, then }) => { + let queryByTestId: ReturnType["queryByTestId"]; + + given("スプラッシュコンポーネントに子要素がある", () => { + // setup in when + }); + + when("スプラッシュ画面をレンダーしてアニメーションが完了する", () => { + const result = render( + + Main + + ); + queryByTestId = result.queryByTestId; + + expect(queryByTestId("animated-splash-container")).toBeTruthy(); + + act(() => { + mockOnFinish?.(); + }); + }); + + then("スプラッシュオーバーレイが非表示になる", () => { + expect(queryByTestId("animated-splash-container")).toBeNull(); + }); + }); +}); diff --git a/jest.config.js b/jest.config.js index 54d16e0..def38a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,12 @@ /** @type {import('jest').Config} */ module.exports = { preset: "jest-expo", - testMatch: ["**/__tests__/**/*.test.ts?(x)"], + testMatch: ["**/__tests__/**/*.test.ts?(x)", "**/__tests__/**/*.steps.ts?(x)"], setupFiles: ["./jest.setup.js"], + transformIgnorePatterns: [ + "/node_modules/(?!(.pnpm|react-native|@react-native|@react-native-community|expo|@expo|@expo-google-fonts|react-navigation|@react-navigation|@sentry/react-native|native-base|@cucumber|jest-cucumber|uuid))", + "/node_modules/react-native-reanimated/plugin/", + ], collectCoverageFrom: ["src/**/*.{ts,tsx}", "!src/**/*.d.ts", "!src/**/*Samples*", "!src/**/screens/**", "!src/**/index.ts"], coverageDirectory: "coverage", coverageReporters: ["text", "lcov", "json-summary"], diff --git a/package.json b/package.json index e115fc0..cf5338c 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint": "^9.39.4", "eslint-config-expo": "~55.0.0", "jest": "~29.7.0", + "jest-cucumber": "^4.5.0", "jest-expo": "~55.0.16", "react-test-renderer": "19.2.0", "typescript": "~5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9cd30..05399e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: jest: specifier: ~29.7.0 version: 29.7.0(@types/node@25.6.0) + jest-cucumber: + specifier: ^4.5.0 + version: 4.5.0(@types/jest@29.5.14)(jest@29.7.0(@types/node@25.6.0)) jest-expo: specifier: ~55.0.16 version: 55.0.16(@babel/core@7.29.0)(expo@55.0.15)(jest@29.7.0(@types/node@25.6.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native/metro-config@0.85.0(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -640,6 +643,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@cucumber/gherkin@28.0.0': + resolution: {integrity: sha512-Ee6zJQq0OmIUPdW0mSnsCsrWA2PZAELNDPICD2pLfs0Oz7RAPgj80UsD2UCtqyAhw2qAR62aqlktKUlai5zl/A==} + + '@cucumber/messages@24.1.0': + resolution: {integrity: sha512-hxVHiBurORcobhVk80I9+JkaKaNXkW6YwGOEFIh/2aO+apAN+5XJgUUWjng9NwqaQrW1sCFuawLB1AuzmBaNdQ==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -875,6 +884,10 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -995,6 +1008,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1450,6 +1467,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1704,6 +1724,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1877,6 +1901,9 @@ packages: brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -1965,6 +1992,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -2196,6 +2226,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2209,6 +2242,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -2664,6 +2700,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -2737,6 +2777,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3086,6 +3131,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3116,6 +3164,20 @@ packages: ts-node: optional: true + jest-cucumber@4.5.0: + resolution: {integrity: sha512-EGVqkeE6xM/wnpWuLuB3AMQs4vNkLDwOuH3bsH2AigphAqDp+k3E+AIh0FAKhJ/1IjLTfZKyupIPRlYN62YZ+A==} + peerDependencies: + '@types/jest': '>=29.5.12' + jest: '>=29.7.0' + vitest: '>=1.4.0' + peerDependenciesMeta: + '@types/jest': + optional: true + jest: + optional: true + vitest: + optional: true + jest-diff@29.7.0: resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3645,6 +3707,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3835,6 +3901,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3869,6 +3938,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4118,6 +4191,10 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.1: + resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} + deprecated: This version has a critical bug in fallback handling. Please upgrade to reflect-metadata@0.2.2 or newer. + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4317,6 +4394,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -4422,6 +4503,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -4680,10 +4765,18 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@7.0.3: resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} hasBin: true + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -4780,6 +4873,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5526,6 +5623,17 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@cucumber/gherkin@28.0.0': + dependencies: + '@cucumber/messages': 24.1.0 + + '@cucumber/messages@24.1.0': + dependencies: + '@types/uuid': 9.0.8 + class-transformer: 0.5.1 + reflect-metadata: 0.2.1 + uuid: 9.0.1 + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -5950,6 +6058,15 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/ttlcache@1.4.1': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -6169,6 +6286,9 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-collection@1.1.7(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -6735,6 +6855,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/uuid@9.0.8': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -6965,6 +7087,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7227,6 +7351,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -7322,6 +7450,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + class-transformer@0.5.1: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -7542,6 +7672,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.334: {} @@ -7550,6 +7682,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -8190,6 +8324,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -8264,6 +8403,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -8627,6 +8775,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -8708,6 +8862,16 @@ snapshots: - babel-plugin-macros - supports-color + jest-cucumber@4.5.0(@types/jest@29.5.14)(jest@29.7.0(@types/node@25.6.0)): + dependencies: + '@cucumber/gherkin': 28.0.0 + callsites: 3.1.0 + glob: 10.5.0 + uuid: 10.0.0 + optionalDependencies: + '@types/jest': 29.5.14 + jest: 29.7.0(@types/node@25.6.0) + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -9607,6 +9771,10 @@ snapshots: dependencies: brace-expansion: 1.1.13 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -9791,6 +9959,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9820,6 +9990,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.3.3 @@ -10114,6 +10289,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.1: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -10339,6 +10516,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -10435,6 +10614,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.9 @@ -10724,8 +10909,12 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@7.0.3: {} + uuid@9.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -10840,6 +11029,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: