diff options
Diffstat (limited to 'src/client/pages/StudyPage.test.tsx')
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 662 |
1 files changed, 162 insertions, 500 deletions
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index d9e9d64..aa33260 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -9,23 +9,55 @@ import { queryClientAtom } from "jotai-tanstack-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { authLoadingAtom, type StudyCard, type StudyData } from "../atoms"; +import { + authLoadingAtom, + isOnlineAtom, + type StudyCard, + type StudyData, +} from "../atoms"; +import type { LocalCard } from "../db"; import { StudyPage } from "./StudyPage"; interface RenderOptions { path?: string; initialStudyData?: StudyData; + online?: boolean; } -const mockDeckGet = vi.fn(); -const mockStudyGet = vi.fn(); -const mockStudyPost = vi.fn(); -const mockHandleResponse = vi.fn(); +const mockSubmitReview = vi.fn(); +const mockUndoReview = vi.fn(); +const mockTriggerSync = vi.fn(); -// Mock shuffle to return array in original order for predictable tests -vi.mock("../utils/shuffle", () => ({ - shuffle: <T,>(array: T[]): T[] => [...array], -})); +vi.mock(import("../sync"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + submitReviewLocal: (args: Parameters<typeof actual.submitReviewLocal>[0]) => + mockSubmitReview(args), + undoReviewLocal: (args: Parameters<typeof actual.undoReviewLocal>[0]) => + mockUndoReview(args), + cacheStudyCards: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock(import("../atoms"), async (importOriginal) => { + const actual = await importOriginal(); + const { atom } = await import("jotai"); + const stubSync = atom(null, async () => { + mockTriggerSync(); + return { + success: true, + pushResult: null, + pullResult: null, + conflictsResolved: 0, + crdtDocumentsStored: 0, + }; + }); + return { + ...actual, + syncActionAtom: stubSync as unknown as typeof actual.syncActionAtom, + }; +}); const mockEditNoteModalOnClose = vi.fn(); const mockEditNoteModalOnNoteUpdated = vi.fn(); @@ -44,7 +76,6 @@ vi.mock("../components/EditNoteModal", () => ({ onClose: () => void; onNoteUpdated: () => void; }) => { - // Store callbacks so tests can call them mockEditNoteModalOnClose.mockImplementation(onClose); mockEditNoteModalOnNoteUpdated.mockImplementation(onNoteUpdated); @@ -77,24 +108,8 @@ vi.mock("../api/client", () => ({ getTokens: vi.fn(), getAuthHeader: vi.fn(), onSessionExpired: vi.fn(() => vi.fn()), - rpc: { - api: { - decks: { - ":id": { - $get: (args: unknown) => mockDeckGet(args), - }, - ":deckId": { - study: { - $get: (args: unknown) => mockStudyGet(args), - ":cardId": { - $post: (args: unknown) => mockStudyPost(args), - }, - }, - }, - }, - }, - }, - handleResponse: (res: unknown) => mockHandleResponse(res), + rpc: { api: { decks: {} } }, + handleResponse: vi.fn(), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -108,8 +123,6 @@ vi.mock("../api/client", () => ({ }, })); -import { ApiClientError, apiClient } from "../api/client"; - let testQueryClient: QueryClient; const mockDeck = { @@ -117,73 +130,90 @@ const mockDeck = { name: "Japanese Vocabulary", }; -const mockFirstCard: StudyCard = { - 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, - reps: 0, - lapses: 0, - noteType: { frontTemplate: "{{Front}}", backTemplate: "{{Back}}" }, - fieldValuesMap: { Front: "Hello", Back: "こんにちは" }, -}; +function makeStudyCard(overrides: Partial<StudyCard>): StudyCard { + return { + 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, + noteType: { frontTemplate: "{{Front}}", backTemplate: "{{Back}}" }, + fieldValuesMap: { Front: "Hello", Back: "こんにちは" }, + ...overrides, + }; +} -const mockSecondCard: StudyCard = { +const mockFirstCard = makeStudyCard({}); +const mockSecondCard = makeStudyCard({ id: "card-2", - deckId: "deck-1", noteId: "note-2", - isReversed: false, front: "Goodbye", back: "さようなら", - state: 0, - due: "2024-01-01T00:00:00Z", - stability: 0, - difficulty: 0, - reps: 0, - lapses: 0, - noteType: { frontTemplate: "{{Front}}", backTemplate: "{{Back}}" }, fieldValuesMap: { Front: "Goodbye", Back: "さようなら" }, -}; - -const mockThirdCard: StudyCard = { +}); +const mockThirdCard = makeStudyCard({ id: "card-3", - deckId: "deck-1", noteId: "note-3", - isReversed: false, front: "Thank you", back: "ありがとう", - state: 0, - due: "2024-01-01T00:00:00Z", - stability: 0, - difficulty: 0, - reps: 0, - lapses: 0, - noteType: { frontTemplate: "{{Front}}", backTemplate: "{{Back}}" }, fieldValuesMap: { Front: "Thank you", Back: "ありがとう" }, -}; +}); const mockDueCards: StudyCard[] = [mockFirstCard, mockSecondCard]; +function makeLocalCard(id: string): LocalCard { + return { + id, + deckId: "deck-1", + noteId: `note-${id}`, + isReversed: false, + front: "", + back: "", + state: 0, + due: new Date("2024-01-01T00:00:00Z"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01T00:00:00Z"), + updatedAt: new Date("2024-01-01T00:00:00Z"), + deletedAt: null, + syncVersion: 0, + _synced: true, + }; +} + function renderWithProviders({ path = "/decks/deck-1/study", initialStudyData, + online = true, }: RenderOptions = {}) { const { hook } = memoryLocation({ path, static: true }); const store = createStore(); store.set(authLoadingAtom, false); store.set(queryClientAtom, testQueryClient); + store.set(isOnlineAtom, online); - // Extract deckId from path const deckIdMatch = path.match(/\/decks\/([^/]+)/); const deckId = deckIdMatch?.[1] ?? "deck-1"; - // Seed query cache if initial data provided if (initialStudyData !== undefined) { testQueryClient.setQueryData(["decks", deckId, "study"], initialStudyData); } @@ -207,19 +237,16 @@ describe("StudyPage", () => { queries: { staleTime: Number.POSITIVE_INFINITY, retry: false }, }, }); - vi.mocked(apiClient.getTokens).mockReturnValue({ - accessToken: "access-token", - refreshToken: "refresh-token", - }); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); - // Default: studyPost returns a resolved promise (needed for unmount flush) - mockStudyPost.mockResolvedValue({}); - - // handleResponse: just pass through whatever it receives - mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); + mockSubmitReview.mockImplementation( + async ({ cardId }: { cardId: string }) => ({ + card: makeLocalCard(cardId), + prevCard: makeLocalCard(cardId), + reviewLogId: `log-${cardId}`, + }), + ); + mockUndoReview.mockResolvedValue(undefined); + mockTriggerSync.mockResolvedValue(undefined); }); afterEach(() => { @@ -229,16 +256,6 @@ describe("StudyPage", () => { }); describe("Loading and Initial State", () => { - it("shows loading state while fetching data", async () => { - mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves - mockStudyGet.mockImplementation(() => new Promise(() => {})); // Never resolves - - renderWithProviders(); - - // Loading state shows spinner (svg with animate-spin class) - expect(document.querySelector(".animate-spin")).toBeDefined(); - }); - it("renders deck name and back link", () => { renderWithProviders({ initialStudyData: { deck: mockDeck, cards: mockDueCards }, @@ -249,39 +266,6 @@ describe("StudyPage", () => { ).toBeDefined(); expect(screen.getByText(/Back to Deck/)).toBeDefined(); }); - - // Skip: Testing RPC endpoint calls is difficult with Suspense in test environment. - it.skip("calls correct RPC endpoints when fetching data", async () => { - mockDeckGet.mockResolvedValue({ deck: mockDeck }); - mockStudyGet.mockResolvedValue({ cards: [] }); - - renderWithProviders(); - - await waitFor(() => { - expect(mockDeckGet).toHaveBeenCalledWith({ - param: { id: "deck-1" }, - }); - }); - expect(mockStudyGet).toHaveBeenCalledWith({ - param: { deckId: "deck-1" }, - }); - }); - }); - - describe("Error Handling", () => { - // Skip: Error boundary tests don't work reliably with Jotai async atoms in test environment. - it.skip("displays error on API failure", async () => { - mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); - mockStudyGet.mockResolvedValue({ cards: [] }); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Deck not found", - ); - }); - }); }); describe("No Cards State", () => { @@ -292,9 +276,6 @@ describe("StudyPage", () => { expect(screen.getByTestId("no-cards")).toBeDefined(); expect(screen.getByText("All caught up!")).toBeDefined(); - expect( - screen.getByText("No cards due for review right now"), - ).toBeDefined(); }); }); @@ -322,7 +303,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - expect(screen.getByTestId("card-front")).toBeDefined(); expect(screen.queryByTestId("rating-buttons")).toBeNull(); }); }); @@ -351,8 +331,6 @@ describe("StudyPage", () => { 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(); }); @@ -373,43 +351,50 @@ describe("StudyPage", () => { }); describe("Rating Submission", () => { - it("moves to next card after rating (API deferred)", async () => { + it("submits review locally and advances to next card", async () => { const user = userEvent.setup(); renderWithProviders({ initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - // Flip card await user.click(screen.getByTestId("card-container")); - - // Rate as Good await user.click(screen.getByTestId("rating-3")); - // Should move to next card without API call await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // API should NOT have been called yet (deferred) - expect(mockStudyPost).not.toHaveBeenCalled(); + expect(mockSubmitReview).toHaveBeenCalledTimes(1); + expect(mockSubmitReview).toHaveBeenCalledWith( + expect.objectContaining({ cardId: "card-1", rating: 3 }), + ); }); - it("flushes previous pending review when rating next card", async () => { + it("triggers sync when online", async () => { const user = userEvent.setup(); - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + online: true, }); + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(mockTriggerSync).toHaveBeenCalled(); + }); + }); + + it("does not trigger sync when offline", async () => { + const user = userEvent.setup(); + renderWithProviders({ - initialStudyData: { - deck: mockDeck, - cards: [mockFirstCard, mockSecondCard, mockThirdCard], - }, + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + online: false, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -417,24 +402,24 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // API not called yet - expect(mockStudyPost).not.toHaveBeenCalled(); + expect(mockTriggerSync).not.toHaveBeenCalled(); + }); + + it("still advances even when offline", async () => { + const user = userEvent.setup(); + + renderWithProviders({ + initialStudyData: { deck: mockDeck, cards: mockDueCards }, + online: false, + }); - // Rate second card — should flush the first await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-4")); + await user.click(screen.getByTestId("rating-3")); await waitFor(() => { - expect(mockStudyPost).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - - // Verify the flushed review was for card-1 - expect(mockStudyPost).toHaveBeenCalledWith( - expect.objectContaining({ - param: { deckId: "deck-1", cardId: "card-1" }, - json: expect.objectContaining({ rating: 3 }), - }), - ); + expect(mockSubmitReview).toHaveBeenCalledTimes(1); }); it("updates remaining count after review", async () => { @@ -458,11 +443,11 @@ describe("StudyPage", () => { }); }); - it("shows error when flush of previous review fails", async () => { + it("shows error when local review submission fails", async () => { const user = userEvent.setup(); - mockStudyPost.mockRejectedValue( - new ApiClientError("Failed to submit review", 500), + mockSubmitReview.mockRejectedValueOnce( + new Error("Failed to submit review"), ); renderWithProviders({ @@ -472,26 +457,19 @@ describe("StudyPage", () => { }, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); await waitFor(() => { - expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); - }); - - // Rate second card — flush fails - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-4")); - - await waitFor(() => { expect(screen.getByRole("alert").textContent).toContain( "Failed to submit review", ); }); - // Should still move to the third card - expect(screen.getByTestId("card-front").textContent).toBe("Thank you"); + // Failed review should not advance — count stays at 3. + expect(screen.getByTestId("remaining-count").textContent).toBe( + "3 remaining", + ); }); }); @@ -503,47 +481,15 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, }); - // 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(); - - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, - }); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - // Review first card - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-3")); - - // Review second card (flushes first) - 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(); @@ -595,15 +541,16 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - await user.keyboard(" "); // Flip - await user.keyboard("3"); // Rate as Good + await user.keyboard(" "); + await user.keyboard("3"); await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // API not called yet (deferred) - expect(mockStudyPost).not.toHaveBeenCalled(); + expect(mockSubmitReview).toHaveBeenCalledWith( + expect.objectContaining({ rating: 3 }), + ); }); it("rates card as Good with Space key when card is flipped", async () => { @@ -613,147 +560,17 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - await user.keyboard(" "); // Flip - await user.keyboard(" "); // Rate as Good (Space) - - await waitFor(() => { - expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); - }); - - // API not called yet (deferred) - expect(mockStudyPost).not.toHaveBeenCalled(); - }); - - it("supports all rating keys (1, 2, 3, 4)", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - await user.keyboard(" "); // Flip - await user.keyboard("1"); // Rate as Again + await user.keyboard(" "); + await user.keyboard(" "); - // API not called yet (deferred), but should move to next card await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - expect(mockStudyPost).not.toHaveBeenCalled(); - }); - }); - - describe("Navigation flushes pending review", () => { - function getSessionCompleteButton(name: string) { - const container = screen.getByTestId("session-complete"); - const buttons = container.querySelectorAll("button"); - for (const button of buttons) { - if (button.textContent?.includes(name)) return button; - } - throw new Error(`Button "${name}" not found in session-complete`); - } - - it("flushes pending review when clicking 'Back to Deck' on session complete", async () => { - const user = userEvent.setup(); - - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, - }); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, - }); - - // Review the only card - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-3")); - - await waitFor(() => { - expect(screen.getByTestId("session-complete")).toBeDefined(); - }); - - // API should NOT have been called yet (still pending) - expect(mockStudyPost).not.toHaveBeenCalled(); - - // Click "Back to Deck" in session complete area - await user.click(getSessionCompleteButton("Back to Deck")); - - // Now the pending review should have been flushed - await waitFor(() => { - expect(mockStudyPost).toHaveBeenCalledTimes(1); - }); - expect(mockStudyPost).toHaveBeenCalledWith( - expect.objectContaining({ - param: { deckId: "deck-1", cardId: "card-1" }, - json: expect.objectContaining({ rating: 3 }), - }), - ); - }); - - it("flushes pending review when clicking 'All Decks' on session complete", async () => { - const user = userEvent.setup(); - - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, - }); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, - }); - - // Review the only card - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-3")); - - await waitFor(() => { - expect(screen.getByTestId("session-complete")).toBeDefined(); - }); - expect(mockStudyPost).not.toHaveBeenCalled(); - - // Click "All Decks" - await user.click(getSessionCompleteButton("All Decks")); - - // Pending review should have been flushed - await waitFor(() => { - expect(mockStudyPost).toHaveBeenCalledTimes(1); - }); - expect(mockStudyPost).toHaveBeenCalledWith( - expect.objectContaining({ - param: { deckId: "deck-1", cardId: "card-1" }, - json: expect.objectContaining({ rating: 3 }), - }), + expect(mockSubmitReview).toHaveBeenCalledWith( + expect.objectContaining({ rating: 3 }), ); }); - - it("navigates even if flush fails", async () => { - const user = userEvent.setup(); - - mockStudyPost.mockRejectedValue(new Error("Network error")); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, - }); - - // Review the only card - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-3")); - - await waitFor(() => { - expect(screen.getByTestId("session-complete")).toBeDefined(); - }); - - // Click "Back to Deck" — flush will fail but navigation should still happen - await user.click(getSessionCompleteButton("Back to Deck")); - - await waitFor(() => { - expect(mockStudyPost).toHaveBeenCalledTimes(1); - }); - - // Buttons should be disabled during navigation (isNavigating = true) - await waitFor(() => { - expect(getSessionCompleteButton("Back to Deck").disabled).toBe(true); - }); - }); }); describe("Undo", () => { @@ -789,7 +606,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -797,28 +613,19 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // Click undo await user.click(screen.getByTestId("undo-button")); - // Should return to the first card await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Hello"); }); - // API should NOT have been called - expect(mockStudyPost).not.toHaveBeenCalled(); - - // Undo button should be gone + expect(mockUndoReview).toHaveBeenCalledTimes(1); expect(screen.queryByTestId("undo-button")).toBeNull(); }); it("decrements completed count on undo", async () => { const user = userEvent.setup(); - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, - }); - renderWithProviders({ initialStudyData: { deck: mockDeck, @@ -826,11 +633,9 @@ describe("StudyPage", () => { }, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); - // Rate second card (flushes first) await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); @@ -843,7 +648,6 @@ describe("StudyPage", () => { ); }); - // Undo await user.click(screen.getByTestId("undo-button")); await waitFor(() => { @@ -860,7 +664,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -868,7 +671,6 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // Press z to undo await user.keyboard("z"); await waitFor(() => { @@ -883,7 +685,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - // Rate first card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -891,7 +692,6 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // Press Ctrl+Z to undo await user.keyboard("{Control>}z{/Control}"); await waitFor(() => { @@ -906,7 +706,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, }); - // Review the only card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -914,7 +713,6 @@ describe("StudyPage", () => { expect(screen.getByTestId("session-complete")).toBeDefined(); }); - // Undo button should be visible on complete screen expect(screen.getByTestId("undo-button")).toBeDefined(); }); @@ -925,7 +723,6 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: [mockFirstCard] }, }); - // Review the only card await user.click(screen.getByTestId("card-container")); await user.click(screen.getByTestId("rating-3")); @@ -933,48 +730,13 @@ describe("StudyPage", () => { expect(screen.getByTestId("session-complete")).toBeDefined(); }); - // Click undo on session complete screen await user.click(screen.getByTestId("undo-button")); - // Should go back to the card await waitFor(() => { expect(screen.getByTestId("card-front").textContent).toBe("Hello"); }); expect(screen.queryByTestId("session-complete")).toBeNull(); }); - - it("flushes pending review on unmount", async () => { - const user = userEvent.setup(); - - mockStudyPost.mockResolvedValue({ - card: { ...mockFirstCard, reps: 1 }, - }); - - const { unmount } = renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - // Rate first card (pending, not sent) - await user.click(screen.getByTestId("card-container")); - await user.click(screen.getByTestId("rating-3")); - - await waitFor(() => { - expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); - }); - - expect(mockStudyPost).not.toHaveBeenCalled(); - - // Unmount triggers flush - unmount(); - - expect(mockStudyPost).toHaveBeenCalledTimes(1); - expect(mockStudyPost).toHaveBeenCalledWith( - expect.objectContaining({ - param: { deckId: "deck-1", cardId: "card-1" }, - json: expect.objectContaining({ rating: 3 }), - }), - ); - }); }); describe("Edit Card", () => { @@ -993,17 +755,12 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - expect(screen.queryByTestId("edit-note-modal")).toBeNull(); - await user.click(screen.getByTestId("edit-card-button")); expect(screen.getByTestId("edit-note-modal")).toBeDefined(); expect( screen.getByTestId("edit-note-modal").getAttribute("data-note-id"), ).toBe("note-1"); - expect( - screen.getByTestId("edit-note-modal").getAttribute("data-deck-id"), - ).toBe("deck-1"); }); it("does not flip card when edit button is clicked", async () => { @@ -1015,7 +772,6 @@ describe("StudyPage", () => { await user.click(screen.getByTestId("edit-card-button")); - // Card should still show front, not back expect(screen.getByTestId("card-front")).toBeDefined(); expect(screen.queryByTestId("card-back")).toBeNull(); }); @@ -1028,28 +784,11 @@ describe("StudyPage", () => { }); await user.click(screen.getByTestId("edit-card-button")); - expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - await user.click(screen.getByTestId("edit-modal-close")); expect(screen.queryByTestId("edit-note-modal")).toBeNull(); }); - it("closes edit modal when save button is clicked", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - await user.click(screen.getByTestId("edit-card-button")); - expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - - await user.click(screen.getByTestId("edit-modal-save")); - - expect(screen.queryByTestId("edit-note-modal")).toBeNull(); - }); - it("opens edit modal with E key", async () => { const user = userEvent.setup(); @@ -1060,25 +799,6 @@ describe("StudyPage", () => { await user.keyboard("e"); expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - expect( - screen.getByTestId("edit-note-modal").getAttribute("data-note-id"), - ).toBe("note-1"); - }); - - it("opens edit modal with E key when card is flipped", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - // Flip card first - await user.keyboard(" "); - expect(screen.getByTestId("card-back")).toBeDefined(); - - await user.keyboard("e"); - - expect(screen.getByTestId("edit-note-modal")).toBeDefined(); }); it("disables keyboard shortcuts while edit modal is open", async () => { @@ -1088,70 +808,12 @@ describe("StudyPage", () => { initialStudyData: { deck: mockDeck, cards: mockDueCards }, }); - // Open edit modal await user.keyboard("e"); expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - // Space should not flip the card await user.keyboard(" "); expect(screen.getByTestId("card-front")).toBeDefined(); expect(screen.queryByTestId("card-back")).toBeNull(); }); - - it("disables rating shortcuts while edit modal is open", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - // Flip card, then open edit modal via E key - await user.keyboard(" "); - expect(screen.getByTestId("card-back")).toBeDefined(); - - await user.keyboard("e"); - expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - - // Number keys should not rate the card - await user.keyboard("3"); - - // Card should still be showing (not moved to next) - expect(screen.getByTestId("card-back")).toBeDefined(); - expect(screen.getByTestId("remaining-count").textContent).toBe( - "2 remaining", - ); - }); - - it("re-enables keyboard shortcuts after edit modal is closed", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - // Open and close edit modal - await user.keyboard("e"); - expect(screen.getByTestId("edit-note-modal")).toBeDefined(); - - await user.click(screen.getByTestId("edit-modal-close")); - expect(screen.queryByTestId("edit-note-modal")).toBeNull(); - - // Space should now flip the card - await user.keyboard(" "); - expect(screen.getByTestId("card-back")).toBeDefined(); - }); - - it("shows edit button when card is flipped", async () => { - const user = userEvent.setup(); - - renderWithProviders({ - initialStudyData: { deck: mockDeck, cards: mockDueCards }, - }); - - await user.click(screen.getByTestId("card-container")); - - expect(screen.getByTestId("card-back")).toBeDefined(); - expect(screen.getByTestId("edit-card-button")).toBeDefined(); - }); }); }); |
