aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/DeckDetailPage.test.tsx
diff options
context:
space:
mode:
authornsfisis <54318333+nsfisis@users.noreply.github.com>2026-01-20 19:55:24 +0900
committerGitHub <noreply@github.com>2026-01-20 19:55:24 +0900
commit86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6 (patch)
tree487d93edd18f6544f576bff57f86ad30bf640080 /src/client/pages/DeckDetailPage.test.tsx
parent188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff)
parent8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (diff)
downloadkioku-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/DeckDetailPage.test.tsx')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx480
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();
});
});