+1
-1
web/package.json
+1
-1
web/package.json
+1
-1
web/src/components/StudySession.test.tsx
+1
-1
web/src/components/StudySession.test.tsx
···
1
-
import type { ReviewCard } from "$lib/store";
1
+
import type { ReviewCard } from "$lib/model";
2
2
import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
3
3
import { afterEach, describe, expect, it, vi } from "vitest";
4
4
import { StudySession } from "./StudySession";
+92
-32
web/src/pages/DeckView.test.tsx
+92
-32
web/src/pages/DeckView.test.tsx
···
1
1
import { api } from "$lib/api";
2
-
import { cleanup, render, screen, waitFor } from "@solidjs/testing-library";
2
+
import { toast } from "$lib/toast";
3
+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library";
3
4
import { JSX } from "solid-js";
4
5
import { afterEach, describe, expect, it, vi } from "vitest";
5
6
import DeckView from "./DeckView";
6
7
7
-
vi.mock("$lib/api", () => ({ api: { get: vi.fn() } }));
8
+
const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() }));
9
+
10
+
vi.mock(
11
+
"$lib/api",
12
+
() => ({
13
+
api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() },
14
+
}),
15
+
);
16
+
17
+
vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
8
18
9
19
vi.mock(
10
20
"@solidjs/router",
11
21
() => ({
12
22
useParams: () => ({ id: "123" }),
23
+
useNavigate: () => mockNavigate,
13
24
A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>,
14
25
}),
15
26
);
16
27
17
28
describe("DeckView", () => {
18
-
afterEach(cleanup);
29
+
afterEach(() => {
30
+
cleanup();
31
+
vi.clearAllMocks();
32
+
});
19
33
20
-
it("renders deck details and cards", async () => {
21
-
const deck = {
22
-
id: "123",
23
-
title: "Test Deck",
24
-
description: "A test deck",
25
-
tags: ["test"],
26
-
visibility: { type: "Public" },
27
-
owner_did: "did:test",
28
-
};
34
+
const mockDeck = {
35
+
id: "123",
36
+
title: "Test Deck",
37
+
description: "A test deck",
38
+
tags: ["test"],
39
+
visibility: { type: "Public" },
40
+
owner_did: "did:test",
41
+
};
29
42
30
-
const cards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }];
43
+
const mockCards = [{ id: "c1", front: "Front 1", back: "Back 1" }, { id: "c2", front: "Front 2", back: "Back 2" }];
31
44
32
-
vi.mocked(api.get).mockImplementation(
33
-
((path: string) => {
34
-
if (path === "/decks/123") {
35
-
return Promise.resolve({ ok: true, json: () => Promise.resolve(deck) });
36
-
}
37
-
if (path === "/decks/123/cards") {
38
-
return Promise.resolve({ ok: true, json: () => Promise.resolve(cards) });
39
-
}
40
-
return Promise.reject(new Error(`Unexpected path: ${path}`));
41
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42
-
}) as any,
45
+
it("renders deck details and cards", async () => {
46
+
vi.mocked(api.getDeck).mockResolvedValue(
47
+
{ ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response,
48
+
);
49
+
vi.mocked(api.getDeckCards).mockResolvedValue(
50
+
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
43
51
);
52
+
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
44
53
45
54
render(() => <DeckView />);
46
55
···
48
57
expect(screen.getByText("A test deck")).toBeInTheDocument();
49
58
expect(screen.getByText("#test")).toBeInTheDocument();
50
59
expect(screen.getByText("Front 1")).toBeInTheDocument();
51
-
expect(screen.getByText("Front 2")).toBeInTheDocument();
52
-
expect(screen.getByText("Back 1")).toBeInTheDocument();
53
60
});
54
61
55
-
it("renders not found state when deck returns error", async () => {
56
-
vi.mocked(api.get).mockImplementation(
57
-
(() => {
58
-
return Promise.resolve({ ok: false });
59
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60
-
}) as any,
62
+
it("handles deck fork flow successfully", async () => {
63
+
vi.mocked(api.getDeck).mockResolvedValue(
64
+
{ ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response,
61
65
);
66
+
vi.mocked(api.getDeckCards).mockResolvedValue(
67
+
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
68
+
);
69
+
vi.mocked(api.forkDeck).mockResolvedValue(
70
+
{ ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response,
71
+
);
72
+
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
62
73
63
74
render(() => <DeckView />);
64
75
76
+
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
77
+
78
+
const forkButton = screen.getByText("Fork Deck", { selector: "button" });
79
+
fireEvent.click(forkButton);
80
+
81
+
const dialog = screen.getByRole("dialog");
82
+
expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument();
83
+
84
+
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
85
+
fireEvent.click(confirmButton);
86
+
87
+
await waitFor(() => {
88
+
expect(api.forkDeck).toHaveBeenCalledWith("123");
89
+
expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!");
90
+
expect(mockNavigate).toHaveBeenCalledWith("/decks/456");
91
+
});
92
+
});
93
+
94
+
it("handles deck fork failure", async () => {
95
+
vi.mocked(api.getDeck).mockResolvedValue(
96
+
{ ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response,
97
+
);
98
+
vi.mocked(api.getDeckCards).mockResolvedValue(
99
+
{ ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response,
100
+
);
101
+
vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response);
102
+
vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
103
+
104
+
render(() => <DeckView />);
105
+
106
+
await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument());
107
+
108
+
const forkButton = screen.getByText("Fork Deck", { selector: "button" });
109
+
fireEvent.click(forkButton);
110
+
111
+
const dialog = screen.getByRole("dialog");
112
+
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
113
+
fireEvent.click(confirmButton);
114
+
115
+
await waitFor(() => {
116
+
expect(api.forkDeck).toHaveBeenCalledWith("123");
117
+
expect(toast.error).toHaveBeenCalledWith("Failed to fork deck.");
118
+
expect(mockNavigate).not.toHaveBeenCalled();
119
+
});
120
+
});
121
+
122
+
it("renders not found state when deck returns error", async () => {
123
+
vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response);
124
+
render(() => <DeckView />);
65
125
await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument());
66
126
});
67
127
});
+30
-15
web/src/pages/DeckView.tsx
+30
-15
web/src/pages/DeckView.tsx
···
1
1
import { CommentSection } from "$components/social/CommentSection";
2
2
import { FollowButton } from "$components/social/FollowButton";
3
3
import { Button } from "$components/ui/Button";
4
+
import { Dialog } from "$components/ui/Dialog";
4
5
import { api } from "$lib/api";
5
6
import type { Card, Deck } from "$lib/model";
6
-
import { A, useParams } from "@solidjs/router";
7
+
import { toast } from "$lib/toast";
8
+
import { A, useNavigate, useParams } from "@solidjs/router";
7
9
import type { Component } from "solid-js";
8
-
import { createResource, For, Show } from "solid-js";
10
+
import { createResource, createSignal, For, Show } from "solid-js";
9
11
10
12
const DeckView: Component = () => {
11
13
const params = useParams();
14
+
const navigate = useNavigate();
15
+
const [showForkDialog, setShowForkDialog] = createSignal(false);
12
16
const [deck] = createResource(() => params.id, async (id) => {
13
17
const res = await api.getDeck(id);
14
18
return res.ok ? (await res.json() as Deck) : null;
···
19
23
});
20
24
21
25
const handleFork = async () => {
22
-
if (!deck()) return;
23
-
// TODO: use modal
24
-
if (confirm(`Fork "${deck()?.title}"?`)) {
26
+
if (deck()) {
25
27
try {
26
28
const res = await api.forkDeck(deck()!.id);
27
29
if (res.ok) {
28
30
const newDeck = await res.json();
29
-
// TODO: use toast
30
-
alert("Deck forked successfully!");
31
-
// TODO: useNavigate
32
-
// navigate(`/decks/${newDeck.id}`);
33
-
window.location.href = `/decks/${newDeck.id}`;
31
+
toast.success("Deck forked successfully!");
32
+
navigate(`/decks/${newDeck.id}`);
34
33
} else {
35
-
// TODO: use toast
36
-
alert("Failed to fork deck.");
34
+
toast.error("Failed to fork deck.");
37
35
}
38
36
} catch (e) {
39
37
console.error(e);
40
-
// TODO: use toast
41
-
alert("Error forking deck.");
38
+
toast.error("Error forking deck.");
39
+
} finally {
40
+
setShowForkDialog(false);
42
41
}
43
42
}
44
43
};
···
89
88
Study Deck (Coming Soon)
90
89
</button>
91
90
<Button
92
-
onClick={handleFork}
91
+
onClick={() => setShowForkDialog(true)}
93
92
variant="secondary"
94
93
class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors">
95
94
Fork Deck
···
147
146
)}
148
147
</Show>
149
148
</Show>
149
+
150
+
<Dialog
151
+
open={showForkDialog()}
152
+
onClose={() => setShowForkDialog(false)}
153
+
title="Fork Deck"
154
+
actions={
155
+
<>
156
+
<Button variant="ghost" onClick={() => setShowForkDialog(false)}>Cancel</Button>
157
+
<Button variant="primary" onClick={handleFork}>Fork Deck</Button>
158
+
</>
159
+
}>
160
+
<p>Are you sure you want to fork "{deck()?.title}"?</p>
161
+
<p class="text-sm text-gray-400 mt-2">
162
+
This will create a copy of this deck in your library that you can study and edit.
163
+
</p>
164
+
</Dialog>
150
165
</div>
151
166
);
152
167
};
+145
web/src/pages/Feed.test.tsx
+145
web/src/pages/Feed.test.tsx
···
1
+
import { api } from "$lib/api";
2
+
import { toast } from "$lib/toast";
3
+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@solidjs/testing-library";
4
+
import { JSX } from "solid-js";
5
+
import { afterEach, describe, expect, it, vi } from "vitest";
6
+
import Feed from "./Feed";
7
+
8
+
const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() }));
9
+
10
+
vi.mock(
11
+
"$lib/api",
12
+
() => ({
13
+
api: {
14
+
getFeedFollows: vi.fn(),
15
+
getFeedTrending: vi.fn(),
16
+
forkDeck: vi.fn(),
17
+
follow: vi.fn(),
18
+
unfollow: vi.fn(),
19
+
getFollowers: vi.fn(),
20
+
},
21
+
}),
22
+
);
23
+
24
+
vi.mock("$lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
25
+
26
+
vi.mock(
27
+
"@solidjs/router",
28
+
() => ({
29
+
useNavigate: () => mockNavigate,
30
+
A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>,
31
+
}),
32
+
);
33
+
34
+
describe("Feed", () => {
35
+
afterEach(() => {
36
+
cleanup();
37
+
vi.clearAllMocks();
38
+
});
39
+
40
+
const mockDecks = [{
41
+
id: "deck1",
42
+
title: "Test Deck 1",
43
+
description: "A test deck",
44
+
tags: ["test"],
45
+
visibility: { type: "Public" },
46
+
owner_did: "did:test:1",
47
+
published_at: "2024-01-01T00:00:00Z",
48
+
}, {
49
+
id: "deck2",
50
+
title: "Test Deck 2",
51
+
description: "Another test deck",
52
+
tags: ["demo"],
53
+
visibility: { type: "Public" },
54
+
owner_did: "did:test:2",
55
+
published_at: null,
56
+
}];
57
+
58
+
it("renders feed with decks from followed users", async () => {
59
+
vi.mocked(api.getFeedFollows).mockResolvedValue(
60
+
{ ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response,
61
+
);
62
+
vi.mocked(api.getFeedTrending).mockResolvedValue(
63
+
{ ok: true, json: () => Promise.resolve([]) } as unknown as Response,
64
+
);
65
+
vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
66
+
67
+
render(() => <Feed />);
68
+
69
+
await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument());
70
+
expect(screen.getByText("Test Deck 2")).toBeInTheDocument();
71
+
});
72
+
73
+
it("shows empty state when no followed decks", async () => {
74
+
vi.mocked(api.getFeedFollows).mockResolvedValue(
75
+
{ ok: true, json: () => Promise.resolve([]) } as unknown as Response,
76
+
);
77
+
vi.mocked(api.getFeedTrending).mockResolvedValue(
78
+
{ ok: true, json: () => Promise.resolve([]) } as unknown as Response,
79
+
);
80
+
81
+
render(() => <Feed />);
82
+
83
+
await waitFor(() => expect(screen.getByText(/No updates from followed users/i)).toBeInTheDocument());
84
+
});
85
+
86
+
it("handles fork flow successfully", async () => {
87
+
vi.mocked(api.getFeedFollows).mockResolvedValue(
88
+
{ ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response,
89
+
);
90
+
vi.mocked(api.getFeedTrending).mockResolvedValue(
91
+
{ ok: true, json: () => Promise.resolve([]) } as unknown as Response,
92
+
);
93
+
vi.mocked(api.forkDeck).mockResolvedValue(
94
+
{ ok: true, json: () => Promise.resolve({ id: "forked-deck" }) } as unknown as Response,
95
+
);
96
+
vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
97
+
98
+
render(() => <Feed />);
99
+
100
+
await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument());
101
+
102
+
const forkButtons = screen.getAllByText("Fork");
103
+
fireEvent.click(forkButtons[0]);
104
+
105
+
const dialog = screen.getByRole("dialog");
106
+
expect(within(dialog).getByText(/Are you sure you want to fork/i)).toBeInTheDocument();
107
+
108
+
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
109
+
fireEvent.click(confirmButton);
110
+
111
+
await waitFor(() => {
112
+
expect(api.forkDeck).toHaveBeenCalledWith("deck1");
113
+
expect(toast.success).toHaveBeenCalledWith("Deck forked successfully!");
114
+
expect(mockNavigate).toHaveBeenCalledWith("/decks/forked-deck");
115
+
});
116
+
});
117
+
118
+
it("handles fork failure", async () => {
119
+
vi.mocked(api.getFeedFollows).mockResolvedValue(
120
+
{ ok: true, json: () => Promise.resolve(mockDecks) } as unknown as Response,
121
+
);
122
+
vi.mocked(api.getFeedTrending).mockResolvedValue(
123
+
{ ok: true, json: () => Promise.resolve([]) } as unknown as Response,
124
+
);
125
+
vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response);
126
+
vi.mocked(api.getFollowers).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response);
127
+
128
+
render(() => <Feed />);
129
+
130
+
await waitFor(() => expect(screen.getByText("Test Deck 1")).toBeInTheDocument());
131
+
132
+
const forkButtons = screen.getAllByText("Fork");
133
+
fireEvent.click(forkButtons[0]);
134
+
135
+
const dialog = screen.getByRole("dialog");
136
+
const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i });
137
+
fireEvent.click(confirmButton);
138
+
139
+
await waitFor(() => {
140
+
expect(api.forkDeck).toHaveBeenCalledWith("deck1");
141
+
expect(toast.error).toHaveBeenCalledWith("Failed to fork deck.");
142
+
expect(mockNavigate).not.toHaveBeenCalled();
143
+
});
144
+
});
145
+
});
+42
-13
web/src/pages/Feed.tsx
+42
-13
web/src/pages/Feed.tsx
···
1
1
import { FollowButton } from "$components/social/FollowButton";
2
2
import { Button } from "$components/ui/Button";
3
3
import { Card } from "$components/ui/Card";
4
+
import { Dialog } from "$components/ui/Dialog";
4
5
import { Tabs } from "$components/ui/Tabs";
5
6
import { api } from "$lib/api";
6
7
import type { Deck } from "$lib/model";
7
-
import { A } from "@solidjs/router";
8
-
import { createResource, For, Match, Show, Switch } from "solid-js";
8
+
import { toast } from "$lib/toast";
9
+
import { A, useNavigate } from "@solidjs/router";
10
+
import { createResource, createSignal, For, Match, Show, Switch } from "solid-js";
9
11
10
12
export default function Feed() {
13
+
const navigate = useNavigate();
14
+
const [forkDialogDeck, setForkDialogDeck] = createSignal<Deck | null>(null);
15
+
11
16
const [followsFeed] = createResource(async () => {
12
17
const res = await api.getFeedFollows();
13
18
return res.ok ? (await res.json() as Deck[]) : [];
···
18
23
return res.ok ? (await res.json() as Deck[]) : [];
19
24
});
20
25
26
+
const handleFork = async () => {
27
+
const deck = forkDialogDeck();
28
+
if (!deck) return;
29
+
try {
30
+
const res = await api.forkDeck(deck.id);
31
+
if (res.ok) {
32
+
const newDeck = await res.json();
33
+
toast.success("Deck forked successfully!");
34
+
navigate(`/decks/${newDeck.id}`);
35
+
} else {
36
+
toast.error("Failed to fork deck.");
37
+
}
38
+
} catch (e) {
39
+
console.error(e);
40
+
toast.error("Error forking deck.");
41
+
} finally {
42
+
setForkDialogDeck(null);
43
+
}
44
+
};
45
+
21
46
const DeckItem = (props: { deck: Deck }) => (
22
47
<Card class="mb-4">
23
48
<div class="flex justify-between items-start">
···
44
69
<A href={`/decks/${props.deck.id}`} class="no-underline">
45
70
<Button variant="secondary" size="sm">View</Button>
46
71
</A>
47
-
<Button
48
-
variant="ghost"
49
-
size="sm"
50
-
onClick={() => {
51
-
// TODO: use modal or toast
52
-
if (confirm("Fork this deck?")) {
53
-
api.forkDeck(props.deck.id).then(() => alert("Forked successfully!"));
54
-
}
55
-
}}>
56
-
Fork
57
-
</Button>
72
+
<Button variant="ghost" size="sm" onClick={() => setForkDialogDeck(props.deck)}>Fork</Button>
58
73
</div>
59
74
</Card>
60
75
);
···
92
107
</Switch>
93
108
)}
94
109
</Tabs>
110
+
111
+
<Dialog
112
+
open={!!forkDialogDeck()}
113
+
onClose={() => setForkDialogDeck(null)}
114
+
title="Fork Deck"
115
+
actions={
116
+
<>
117
+
<Button variant="ghost" onClick={() => setForkDialogDeck(null)}>Cancel</Button>
118
+
<Button variant="primary" onClick={handleFork}>Fork Deck</Button>
119
+
</>
120
+
}>
121
+
<p>Are you sure you want to fork "{forkDialogDeck()?.title}"?</p>
122
+
<p class="text-sm text-gray-400 mt-2">This will create a copy of this deck in your library.</p>
123
+
</Dialog>
95
124
</div>
96
125
);
97
126
}