diff options
Diffstat (limited to 'src/client/pages/DeckDetailPage.test.tsx')
| -rw-r--r-- | src/client/pages/DeckDetailPage.test.tsx | 480 |
1 files changed, 43 insertions, 437 deletions
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index b138a0b..903edb7 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -1,8 +1,7 @@ /** * @vitest-environment jsdom */ -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { cleanup, render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; @@ -19,7 +18,6 @@ import { DeckDetailPage } from "./DeckDetailPage"; const mockDeckGet = vi.fn(); const mockCardsGet = vi.fn(); -const mockNoteDelete = vi.fn(); const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ @@ -40,11 +38,6 @@ vi.mock("../api/client", () => ({ cards: { $get: (args: unknown) => mockCardsGet(args), }, - notes: { - ":noteId": { - $delete: (args: unknown) => mockNoteDelete(args), - }, - }, }, }, }, @@ -63,7 +56,7 @@ vi.mock("../api/client", () => ({ }, })); -import { ApiClientError, apiClient } from "../api/client"; +import { apiClient } from "../api/client"; const mockDeck = { id: "deck-1", @@ -75,8 +68,7 @@ const mockDeck = { updatedAt: "2024-01-01T00:00:00Z", }; -// Basic note-based cards (each with its own note) -const mockBasicCards = [ +const mockCards = [ { id: "card-1", deckId: "deck-1", @@ -85,7 +77,7 @@ const mockBasicCards = [ front: "Hello", back: "こんにちは", state: 0, - due: "2024-01-01T00:00:00Z", + due: "2099-01-01T00:00:00Z", // Not due yet (future date) stability: 0, difficulty: 0, elapsedDays: 0, @@ -106,7 +98,7 @@ const mockBasicCards = [ front: "Goodbye", back: "さようなら", state: 2, - due: "2024-01-02T00:00:00Z", + due: new Date().toISOString(), // Due now stability: 5.5, difficulty: 5.0, elapsedDays: 1, @@ -121,55 +113,6 @@ const mockBasicCards = [ }, ]; -// 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; @@ -220,15 +163,10 @@ describe("DeckDetailPage", () => { Authorization: "Bearer access-token", }); - // handleResponse simulates actual behavior - // - If response is a plain object (from mocked RPC), pass through - // - If response is Response-like with ok/status, handle properly mockHandleResponse.mockImplementation(async (res) => { - // Plain object (already the data) - pass through if (res.ok === undefined && res.status === undefined) { return res; } - // Response-like object if (!res.ok) { const body = await res.json?.().catch(() => ({})); throw new Error( @@ -259,418 +197,86 @@ describe("DeckDetailPage", () => { }); it("shows loading state while fetching data", async () => { - mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves - mockCardsGet.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDeckGet.mockImplementation(() => new Promise(() => {})); + mockCardsGet.mockImplementation(() => new Promise(() => {})); renderWithProviders(); - // Loading state shows spinner (svg with animate-spin class) expect(document.querySelector(".animate-spin")).toBeDefined(); }); - it("displays empty state when no cards exist", () => { + it("does not show description if deck has none", () => { + const deckWithoutDescription = { ...mockDeck, description: null }; renderWithProviders({ - initialDeck: mockDeck, + initialDeck: deckWithoutDescription, initialCards: [], }); - expect(screen.getByText("No cards yet")).toBeDefined(); - expect(screen.getByText("Add notes to start studying")).toBeDefined(); + expect( + screen.getByRole("heading", { name: "Japanese Vocabulary" }), + ).toBeDefined(); + expect(screen.queryByText("Common Japanese words")).toBeNull(); }); - it("displays list of cards", () => { + it("displays Study Now button", () => { renderWithProviders({ initialDeck: mockDeck, initialCards: mockCards, }); - expect(screen.getByText("Hello")).toBeDefined(); - expect(screen.getByText("こんにちは")).toBeDefined(); - expect(screen.getByText("Goodbye")).toBeDefined(); - expect(screen.getByText("さようなら")).toBeDefined(); + const studyButton = screen.getByRole("link", { name: /Study Now/ }); + expect(studyButton).toBeDefined(); + expect(studyButton.getAttribute("href")).toBe("/decks/deck-1/study"); }); - it("displays card count", () => { + it("displays View Cards link", () => { renderWithProviders({ initialDeck: mockDeck, initialCards: mockCards, }); - expect(screen.getByText("(2)")).toBeDefined(); + const viewCardsLink = screen.getByRole("link", { name: /View Cards/ }); + expect(viewCardsLink).toBeDefined(); + expect(viewCardsLink.getAttribute("href")).toBe("/decks/deck-1/cards"); }); - it("displays card state labels", () => { + it("displays total card count", () => { renderWithProviders({ initialDeck: mockDeck, initialCards: mockCards, }); - expect(screen.getByText("New")).toBeDefined(); - expect(screen.getByText("Review")).toBeDefined(); + const totalCardsLabel = screen.getByText("Total Cards"); + expect(totalCardsLabel).toBeDefined(); + // Find the count within the same container + const totalCardsContainer = totalCardsLabel.parentElement; + expect(totalCardsContainer?.querySelector(".text-ink")?.textContent).toBe( + "2", + ); }); - it("displays card stats (reps and lapses)", () => { + it("displays due card count", () => { renderWithProviders({ initialDeck: mockDeck, initialCards: mockCards, }); - expect(screen.getByText("0 reviews")).toBeDefined(); - expect(screen.getByText("5 reviews")).toBeDefined(); - expect(screen.getByText("1 lapses")).toBeDefined(); - }); - - // Note: Error display tests are skipped - see HomePage.test.tsx for details - it.skip("displays error on API failure for deck", async () => { - mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); - mockCardsGet.mockResolvedValue({ cards: [] }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain("Deck not found"); - }); - }); - - it.skip("displays error on API failure for cards", async () => { - mockDeckGet.mockResolvedValue({ deck: mockDeck }); - mockCardsGet.mockRejectedValue( - new ApiClientError("Failed to load cards", 500), - ); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Failed to load cards", - ); - }); - }); - - // Skip: Testing RPC endpoint calls is difficult with Suspense in test environment. - // The async atoms don't complete their fetch cycle reliably in vitest. - // The actual API integration is tested via hydration-based UI tests. - it.skip("calls correct RPC endpoints when fetching data", async () => { - mockDeckGet.mockResolvedValue({ deck: mockDeck }); - mockCardsGet.mockResolvedValue({ cards: [] }); - - renderWithProviders(); - - await waitFor( - () => { - expect(mockDeckGet).toHaveBeenCalledWith({ - param: { id: "deck-1" }, - }); - }, - { timeout: 3000 }, - ); - expect(mockCardsGet).toHaveBeenCalledWith({ - param: { deckId: "deck-1" }, - }); + const dueLabel = screen.getByText("Due Today"); + expect(dueLabel).toBeDefined(); + // Find the count within the same container (one card is due) + const dueContainer = dueLabel.parentElement; + expect(dueContainer?.querySelector(".text-primary")?.textContent).toBe("1"); }); - it("does not show description if deck has none", () => { - const deckWithoutDescription = { ...mockDeck, description: null }; + it("does not display card list (cards are hidden)", () => { renderWithProviders({ - initialDeck: deckWithoutDescription, - initialCards: [], - }); - - expect( - screen.getByRole("heading", { name: "Japanese Vocabulary" }), - ).toBeDefined(); - - // No description should be shown - 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(); - - // After mutation, the list will refetch - 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); - } - - // Find the Delete button in the modal - 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); - } - - // Wait for modal to close and list to refresh - await waitFor(() => { - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - // Verify DELETE request was made to notes endpoint - expect(mockNoteDelete).toHaveBeenCalledWith({ - param: { deckId: "deck-1", noteId: "note-1" }, - }); - - // Verify card count updated - 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); - } - - // Find the Delete button in the modal - 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); - } - - // Error should be displayed in the modal - 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, - }); - - // Should show note group container - expect(screen.getByTestId("note-group")).toBeDefined(); - - // Should display both cards within the note group - 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, - }); - - // Should show "Note (2 cards)" since there are 2 cards from the same note - 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(); - - // After mutation, the list will refetch - 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); - - // Confirm deletion in modal - 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); - } - - // Wait for modal to close - await waitFor(() => { - expect(screen.queryByRole("dialog")).toBeNull(); - }); - - // Verify DELETE request was made to notes endpoint - expect(mockNoteDelete).toHaveBeenCalledWith({ - param: { deckId: "deck-1", noteId: "note-1" }, - }); - - // Should show empty state after deletion - await waitFor(() => { - expect(screen.getByText("No cards yet")).toBeDefined(); - }); + initialDeck: mockDeck, + initialCards: mockCards, }); - it("displays note preview from normal card content", () => { - renderWithProviders({ - initialDeck: mockDeck, - initialCards: mockNoteBasedCards, - }); - - expect(screen.getByTestId("note-group")).toBeDefined(); - - // The normal card's front/back should be displayed as preview - expect(screen.getByText("Apple")).toBeDefined(); - expect(screen.getByText("りんご")).toBeDefined(); - }); + // Card content should NOT be visible on deck detail page + expect(screen.queryByText("Hello")).toBeNull(); + expect(screen.queryByText("こんにちは")).toBeNull(); + expect(screen.queryByText("Goodbye")).toBeNull(); }); }); |
