aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/StudyPage.test.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages/StudyPage.test.tsx')
-rw-r--r--src/client/pages/StudyPage.test.tsx662
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();
- });
});
});