diff options
| author | Claude <noreply@anthropic.com> | 2026-01-20 01:16:15 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-01-20 01:16:15 +0000 |
| commit | 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (patch) | |
| tree | 487d93edd18f6544f576bff57f86ad30bf640080 /src/client/pages/DeckCardsPage.test.tsx | |
| parent | 188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff) | |
| download | kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.gz kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.zst kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.zip | |
feat(deck): separate card list from deck detail page
Separate the card list view from the deck learning page to prevent users
from seeing cards they are about to study. The deck detail page now shows
only study statistics with a "Study Now" button and a "View Cards" link.
- Add new DeckCardsPage component at /decks/:deckId/cards for managing cards
- Simplify DeckDetailPage to show deck stats and navigation buttons
- Update routing in App.tsx with proper route ordering
- Add comprehensive tests for both 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(); + }); + }); +}); |
