aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/DeckDetailPage.test.tsx
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 13:40:59 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 13:40:59 +0900
commitad950be46447a74e523eeb2bd278641600dff2fb (patch)
treeadbb05bc3e7a76740e9c689d8b3c5b649d0ffe49 /src/client/pages/DeckDetailPage.test.tsx
parent25f29d1016a6083f97d3ed094142ff1ca9faf775 (diff)
downloadkioku-ad950be46447a74e523eeb2bd278641600dff2fb.tar.gz
kioku-ad950be46447a74e523eeb2bd278641600dff2fb.tar.zst
kioku-ad950be46447a74e523eeb2bd278641600dff2fb.zip
feat(client): group cards by note in DeckDetailPage
Display note-based cards as grouped items showing all cards generated from the same note, with note-level edit/delete actions. Legacy cards without note association are shown separately with a "Legacy" badge. - Add NoteGroupCard component for displaying note groups with card stats - Add LegacyCardItem component for backward-compatible card display - Add DeleteNoteModal for deleting notes and their cards - Show Normal/Reversed badges for cards within note groups ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages/DeckDetailPage.test.tsx')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx307
1 files changed, 306 insertions, 1 deletions
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index a6b8531..35303d9 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -51,10 +51,13 @@ const mockDeck = {
updatedAt: "2024-01-01T00:00:00Z",
};
-const mockCards = [
+// Legacy cards (no noteId) for backward compatibility testing
+const mockLegacyCards = [
{
id: "card-1",
deckId: "deck-1",
+ noteId: null,
+ isReversed: null,
front: "Hello",
back: "ใ“ใ‚“ใซใกใฏ",
state: 0,
@@ -74,6 +77,8 @@ const mockCards = [
{
id: "card-2",
deckId: "deck-1",
+ noteId: null,
+ isReversed: null,
front: "Goodbye",
back: "ใ•ใ‚ˆใ†ใชใ‚‰",
state: 2,
@@ -92,6 +97,58 @@ const mockCards = [
},
];
+// 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,
+ },
+];
+
+// Mixed cards (both legacy and note-based)
+const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards];
+
+// Alias for backward compatibility in existing tests
+const mockCards = mockLegacyCards;
+
function renderWithProviders(path = "/decks/deck-1") {
const { hook } = memoryLocation({ path, static: true });
return render(
@@ -583,4 +640,252 @@ describe("DeckDetailPage", () => {
});
});
});
+
+ describe("Card Grouping by Note", () => {
+ it("groups cards by noteId and displays as note groups", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ // 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("displays legacy cards separately from note groups", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockMixedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ // Should show both note groups and legacy cards
+ expect(screen.getByTestId("note-group")).toBeDefined();
+ });
+
+ const legacyCards = screen.getAllByTestId("legacy-card");
+ expect(legacyCards.length).toBe(2); // 2 legacy cards
+
+ // Should show "Legacy" badge for legacy cards
+ const legacyBadges = screen.getAllByText("Legacy");
+ expect(legacyBadges.length).toBe(2);
+ });
+
+ it("shows Normal and Reversed badges for note-based cards", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("Normal")).toBeDefined();
+ });
+
+ expect(screen.getByText("Reversed")).toBeDefined();
+ });
+
+ it("shows note card count in note group header", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ // 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", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ 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", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ 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();
+
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("note-group")).toBeDefined();
+ });
+
+ 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();
+
+ mockFetch
+ // Initial load
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ })
+ // Delete request
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ success: true }),
+ })
+ // Refresh cards after deletion
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: [] }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("note-group")).toBeDefined();
+ });
+
+ 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(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", {
+ method: "DELETE",
+ headers: { Authorization: "Bearer access-token" },
+ });
+
+ // Should show empty state after deletion
+ await waitFor(() => {
+ expect(screen.getByText("No cards yet")).toBeDefined();
+ });
+ });
+
+ it("displays note preview from normal card content", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ deck: mockDeck }),
+ })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({ cards: mockNoteBasedCards }),
+ });
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ 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();
+ });
+ });
});