diff options
| author | nsfisis <54318333+nsfisis@users.noreply.github.com> | 2026-01-20 19:55:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-20 19:55:24 +0900 |
| commit | 86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6 (patch) | |
| tree | 487d93edd18f6544f576bff57f86ad30bf640080 /src/client/pages/DeckCardsPage.test.tsx | |
| parent | 188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff) | |
| parent | 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (diff) | |
| download | kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.gz kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.zst kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.zip | |
Merge pull request #10 from nsfisis/claude/separate-deck-learning-view-jb2rV
Separate deck detail and card learning pages
Diffstat (limited to 'src/client/pages/DeckCardsPage.test.tsx')
| -rw-r--r-- | src/client/pages/DeckCardsPage.test.tsx | 604 |
1 files changed, 604 insertions, 0 deletions
diff --git a/src/client/pages/DeckCardsPage.test.tsx b/src/client/pages/DeckCardsPage.test.tsx new file mode 100644 index 0000000..d70da83 --- /dev/null +++ b/src/client/pages/DeckCardsPage.test.tsx @@ -0,0 +1,604 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Route, Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { + authLoadingAtom, + type Card, + cardsByDeckAtomFamily, + type Deck, + deckByIdAtomFamily, +} from "../atoms"; +import { clearAtomFamilyCaches } from "../atoms/utils"; +import { DeckCardsPage } from "./DeckCardsPage"; + +const mockDeckGet = vi.fn(); +const mockCardsGet = vi.fn(); +const mockNoteDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + +vi.mock("../api/client", () => ({ + apiClient: { + login: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn(), + getTokens: vi.fn(), + getAuthHeader: vi.fn(), + onSessionExpired: vi.fn(() => vi.fn()), + rpc: { + api: { + decks: { + ":id": { + $get: (args: unknown) => mockDeckGet(args), + }, + ":deckId": { + cards: { + $get: (args: unknown) => mockCardsGet(args), + }, + notes: { + ":noteId": { + $delete: (args: unknown) => mockNoteDelete(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +import { ApiClientError, apiClient } from "../api/client"; + +const mockDeck = { + id: "deck-1", + name: "Japanese Vocabulary", + description: "Common Japanese words", + newCardsPerDay: 20, + dueCardCount: 0, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", +}; + +// Basic note-based cards (each with its own note) +const mockBasicCards = [ + { + id: "card-1", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Hello", + back: "こんにちは", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, + { + id: "card-2", + deckId: "deck-1", + noteId: "note-2", + isReversed: false, + front: "Goodbye", + back: "さようなら", + state: 2, + due: "2024-01-02T00:00:00Z", + stability: 5.5, + difficulty: 5.0, + elapsedDays: 1, + scheduledDays: 7, + reps: 5, + lapses: 1, + lastReview: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, +]; + +// Note-based cards (with noteId) +const mockNoteBasedCards = [ + { + id: "card-3", + deckId: "deck-1", + noteId: "note-1", + isReversed: false, + front: "Apple", + back: "りんご", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, + { + id: "card-4", + deckId: "deck-1", + noteId: "note-1", + isReversed: true, + front: "りんご", + back: "Apple", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 2, + lapses: 0, + lastReview: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, +]; + +// Alias for existing tests +const mockCards = mockBasicCards; + +interface RenderOptions { + path?: string; + initialDeck?: Deck; + initialCards?: Card[]; +} + +function renderWithProviders({ + path = "/decks/deck-1/cards", + initialDeck, + initialCards, +}: RenderOptions = {}) { + const { hook } = memoryLocation({ path, static: true }); + const store = createStore(); + store.set(authLoadingAtom, false); + + // Extract deckId from path + const deckIdMatch = path.match(/\/decks\/([^/]+)/); + const deckId = deckIdMatch?.[1] ?? "deck-1"; + + // Hydrate atoms if initial data provided + if (initialDeck !== undefined) { + store.set(deckByIdAtomFamily(deckId), initialDeck); + } + if (initialCards !== undefined) { + store.set(cardsByDeckAtomFamily(deckId), initialCards); + } + + return render( + <Provider store={store}> + <Router hook={hook}> + <Route path="/decks/:deckId/cards"> + <DeckCardsPage /> + </Route> + </Router> + </Provider>, + ); +} + +describe("DeckCardsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.getTokens).mockReturnValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + vi.mocked(apiClient.getAuthHeader).mockReturnValue({ + Authorization: "Bearer access-token", + }); + + // handleResponse simulates actual behavior + mockHandleResponse.mockImplementation(async (res) => { + if (res.ok === undefined && res.status === undefined) { + return res; + } + if (!res.ok) { + const body = await res.json?.().catch(() => ({})); + throw new Error( + body?.error || `Request failed with status ${res.status}`, + ); + } + return typeof res.json === "function" ? res.json() : res; + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + clearAtomFamilyCaches(); + }); + + it("renders back link and deck name", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect( + screen.getByRole("heading", { name: "Japanese Vocabulary" }), + ).toBeDefined(); + expect(screen.getByText(/Back to Deck/)).toBeDefined(); + expect(screen.getByText("Common Japanese words")).toBeDefined(); + }); + + it("shows loading state while fetching data", async () => { + mockDeckGet.mockImplementation(() => new Promise(() => {})); + mockCardsGet.mockImplementation(() => new Promise(() => {})); + + renderWithProviders(); + + expect(document.querySelector(".animate-spin")).toBeDefined(); + }); + + it("displays empty state when no cards exist", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: [], + }); + + expect(screen.getByText("No cards yet")).toBeDefined(); + expect(screen.getByText("Add notes to start studying")).toBeDefined(); + }); + + it("displays list of cards", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect(screen.getByText("Hello")).toBeDefined(); + expect(screen.getByText("こんにちは")).toBeDefined(); + expect(screen.getByText("Goodbye")).toBeDefined(); + expect(screen.getByText("さようなら")).toBeDefined(); + }); + + it("displays card count", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect(screen.getByText("(2)")).toBeDefined(); + }); + + it("displays card state labels", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect(screen.getByText("New")).toBeDefined(); + expect(screen.getByText("Review")).toBeDefined(); + }); + + it("displays card stats (reps and lapses)", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect(screen.getByText("0 reviews")).toBeDefined(); + expect(screen.getByText("5 reviews")).toBeDefined(); + expect(screen.getByText("1 lapses")).toBeDefined(); + }); + + it("does not show description if deck has none", () => { + const deckWithoutDescription = { ...mockDeck, description: null }; + renderWithProviders({ + initialDeck: deckWithoutDescription, + initialCards: [], + }); + + expect( + screen.getByRole("heading", { name: "Japanese Vocabulary" }), + ).toBeDefined(); + expect(screen.queryByText("Common Japanese words")).toBeNull(); + }); + + describe("Delete Note", () => { + it("shows Delete button for each note", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + expect(screen.getByText("Hello")).toBeDefined(); + + const deleteButtons = screen.getAllByRole("button", { + name: "Delete note", + }); + expect(deleteButtons.length).toBe(2); + }); + + it("opens delete confirmation modal when Delete button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + const deleteButtons = screen.getAllByRole("button", { + name: "Delete note", + }); + const firstDeleteButton = deleteButtons[0]; + if (firstDeleteButton) { + await user.click(firstDeleteButton); + } + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Delete Note" }), + ).toBeDefined(); + }); + + it("closes delete modal when Cancel is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + const deleteButtons = screen.getAllByRole("button", { + name: "Delete note", + }); + const firstDeleteButton = deleteButtons[0]; + if (firstDeleteButton) { + await user.click(firstDeleteButton); + } + + expect(screen.getByRole("dialog")).toBeDefined(); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("deletes note and refreshes list on confirmation", async () => { + const user = userEvent.setup(); + + mockCardsGet.mockResolvedValue({ + cards: [mockCards[1]], + }); + mockNoteDelete.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + const deleteButtons = screen.getAllByRole("button", { + name: "Delete note", + }); + const firstDeleteButton = deleteButtons[0]; + if (firstDeleteButton) { + await user.click(firstDeleteButton); + } + + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), + ); + if (confirmDeleteButton) { + await user.click(confirmDeleteButton); + } + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, + }); + + await waitFor(() => { + expect(screen.getByText("(1)")).toBeDefined(); + }); + }); + + it("displays error when delete fails", async () => { + const user = userEvent.setup(); + + mockNoteDelete.mockRejectedValue( + new ApiClientError("Failed to delete note", 500), + ); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockCards, + }); + + const deleteButtons = screen.getAllByRole("button", { + name: "Delete note", + }); + const firstDeleteButton = deleteButtons[0]; + if (firstDeleteButton) { + await user.click(firstDeleteButton); + } + + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), + ); + if (confirmDeleteButton) { + await user.click(confirmDeleteButton); + } + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Failed to delete note", + ); + }); + }); + }); + + describe("Card Grouping by Note", () => { + it("groups cards by noteId and displays as note groups", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByTestId("note-group")).toBeDefined(); + + const noteCards = screen.getAllByTestId("note-card"); + expect(noteCards.length).toBe(2); + }); + + it("shows Normal and Reversed badges for note-based cards", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByText("Normal")).toBeDefined(); + expect(screen.getByText("Reversed")).toBeDefined(); + }); + + it("shows note card count in note group header", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByText("Note (2 cards)")).toBeDefined(); + }); + + it("shows edit note button for note groups", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByTestId("note-group")).toBeDefined(); + + const editNoteButton = screen.getByRole("button", { name: "Edit note" }); + expect(editNoteButton).toBeDefined(); + }); + + it("shows delete note button for note groups", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByTestId("note-group")).toBeDefined(); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + expect(deleteNoteButton).toBeDefined(); + }); + + it("opens delete note modal when delete button is clicked", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + expect(screen.getByRole("dialog")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Delete Note" }), + ).toBeDefined(); + }); + + it("deletes note and refreshes list when confirmed", async () => { + const user = userEvent.setup(); + + mockCardsGet.mockResolvedValue({ cards: [] }); + mockNoteDelete.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + const deleteNoteButton = screen.getByRole("button", { + name: "Delete note", + }); + await user.click(deleteNoteButton); + + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), + ); + if (confirmDeleteButton) { + await user.click(confirmDeleteButton); + } + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, + }); + + await waitFor(() => { + expect(screen.getByText("No cards yet")).toBeDefined(); + }); + }); + + it("displays note preview from normal card content", () => { + renderWithProviders({ + initialDeck: mockDeck, + initialCards: mockNoteBasedCards, + }); + + expect(screen.getByTestId("note-group")).toBeDefined(); + + expect(screen.getByText("Apple")).toBeDefined(); + expect(screen.getByText("りんご")).toBeDefined(); + }); + }); +}); |
