aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/StudyPage.test.tsx
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:50:08 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:50:08 +0900
commitf443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 (patch)
tree571fa2cdd1959598e623fdd839c07d63b03b1124 /src/client/pages/StudyPage.test.tsx
parentb965d9432b4037dd2f65bb4c8690965e090228ca (diff)
downloadkioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.gz
kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.zst
kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.zip
feat(client): add study session page with card flip and rating UI
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages/StudyPage.test.tsx')
-rw-r--r--src/client/pages/StudyPage.test.tsx762
1 files changed, 762 insertions, 0 deletions
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(
+ <Router hook={hook}>
+ <AuthProvider>
+ <Route path="/decks/:deckId/study">
+ <StudyPage />
+ </Route>
+ </AuthProvider>
+ </Router>,
+ );
+}
+
+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'),
+ }),
+ );
+ });
+ });
+});