From f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 18:50:08 +0900 Subject: feat(client): add study session page with card flip and rating UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete study flow frontend: - Study session page with card display and flip interaction - Rating buttons (Again, Hard, Good, Easy) with keyboard shortcuts - Progress display showing remaining cards count - Session complete screen with review summary - Study Now button on deck detail page đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/StudyPage.test.tsx | 762 ++++++++++++++++++++++++++++++++++++ 1 file changed, 762 insertions(+) create mode 100644 src/client/pages/StudyPage.test.tsx (limited to 'src/client/pages/StudyPage.test.tsx') diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx new file mode 100644 index 0000000..bab9193 --- /dev/null +++ b/src/client/pages/StudyPage.test.tsx @@ -0,0 +1,762 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Route, Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { apiClient } from "../api/client"; +import { AuthProvider } from "../stores"; +import { StudyPage } from "./StudyPage"; + +vi.mock("../api/client", () => ({ + apiClient: { + login: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn(), + getTokens: vi.fn(), + getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + $get: vi.fn(), + $post: vi.fn(), + }, + }, + }, + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockDeck = { + id: "deck-1", + name: "Japanese Vocabulary", + description: "Common Japanese words", + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", +}; + +const mockDueCards = [ + { + id: "card-1", + deckId: "deck-1", + 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", + front: "Goodbye", + 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, + }, +]; + +function renderWithProviders(path = "/decks/deck-1/study") { + const { hook } = memoryLocation({ path, static: true }); + return render( + + + + + + + , + ); +} + +describe("StudyPage", () => { + 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", + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + describe("Loading and Initial State", () => { + it("shows loading state while fetching data", async () => { + mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + + renderWithProviders(); + + expect(screen.getByText("Loading study session...")).toBeDefined(); + }); + + it("renders deck name and back link", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + ).toBeDefined(); + }); + + expect(screen.getByText(/Back to Deck/)).toBeDefined(); + }); + + it("passes auth header when fetching data", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { + headers: { Authorization: "Bearer access-token" }, + }); + }); + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/study", { + headers: { Authorization: "Bearer access-token" }, + }); + }); + }); + + describe("Error Handling", () => { + it("displays error on API failure", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: "Deck not found" }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Deck not found", + ); + }); + }); + + it("allows retry after error", async () => { + const user = userEvent.setup(); + // First call fails + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Server error" }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Server error" }), + }) + // Retry succeeds + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeDefined(); + }); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + ).toBeDefined(); + }); + }); + }); + + describe("No Cards State", () => { + it("shows no cards message when deck has no due cards", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("no-cards")).toBeDefined(); + }); + expect(screen.getByText("No cards to study")).toBeDefined(); + expect( + screen.getByText("There are no due cards in this deck right now."), + ).toBeDefined(); + }); + }); + + describe("Card Display and Progress", () => { + it("shows remaining cards count", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "2 remaining", + ); + }); + }); + + it("displays the front of the first card", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Hello"); + }); + }); + + it("does not show rating buttons before card is flipped", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + expect(screen.queryByTestId("rating-buttons")).toBeNull(); + }); + }); + + describe("Card Flip Interaction", () => { + it("reveals answer when card is clicked", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("card-back").textContent).toBe("こんにづは"); + }); + + it("shows rating buttons after card is flipped", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("rating-buttons")).toBeDefined(); + expect(screen.getByTestId("rating-1")).toBeDefined(); + expect(screen.getByTestId("rating-2")).toBeDefined(); + expect(screen.getByTestId("rating-3")).toBeDefined(); + expect(screen.getByTestId("rating-4")).toBeDefined(); + }); + + it("displays rating labels on buttons", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("rating-1").textContent).toContain("Again"); + expect(screen.getByTestId("rating-2").textContent).toContain("Hard"); + expect(screen.getByTestId("rating-3").textContent).toContain("Good"); + expect(screen.getByTestId("rating-4").textContent).toContain("Easy"); + }); + }); + + describe("Rating Submission", () => { + it("submits review and moves to next card", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + // Submit review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Flip card + await user.click(screen.getByTestId("card-container")); + + // Rate as Good + await user.click(screen.getByTestId("rating-3")); + + // Should move to next card + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + + // Verify API was called + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }), + body: expect.stringContaining('"rating":3'), + }), + ); + }); + + it("updates remaining count after review", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "2 remaining", + ); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "1 remaining", + ); + }); + }); + + it("shows error when rating submission fails", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Failed to submit review" }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Failed to submit review", + ); + }); + }); + }); + + describe("Session Complete", () => { + it("shows session complete screen after all cards reviewed", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [mockDueCards[0]] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Review the only card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + // Should show session complete + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + expect(screen.getByText("Session Complete!")).toBeDefined(); + expect(screen.getByTestId("completed-count").textContent).toBe("1"); + }); + + it("shows correct count for multiple cards reviewed", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + // First review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }) + // Second review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[1], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Review first card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + // Review second card + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-4")); + + // Should show session complete with 2 cards + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + expect(screen.getByTestId("completed-count").textContent).toBe("2"); + }); + + it("provides navigation links after session complete", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [mockDueCards[0]] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + + expect(screen.getByText("Back to Deck")).toBeDefined(); + expect(screen.getByText("All Decks")).toBeDefined(); + }); + }); + + describe("Keyboard Shortcuts", () => { + it("flips card with Space key", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); + + expect(screen.getByTestId("card-back").textContent).toBe("こんにづは"); + }); + + it("flips card with Enter key", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard("{Enter}"); + + expect(screen.getByTestId("card-back").textContent).toBe("こんにづは"); + }); + + it("rates card with number keys", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); // Flip + await user.keyboard("3"); // Rate as Good + + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + body: expect.stringContaining('"rating":3'), + }), + ); + }); + + it("supports all rating keys (1, 2, 3, 4)", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); // Flip + await user.keyboard("1"); // Rate as Again + + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + body: expect.stringContaining('"rating":1'), + }), + ); + }); + }); +}); -- cgit v1.2.3-70-g09d2