Skip to content

Commit 91cfcb3

Browse files
authored
test: increment global coverage
1 parent 6b9c3e2 commit 91cfcb3

15 files changed

Lines changed: 1411 additions & 3 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ dist/
22
.idea/
33
.astro/
44
.vercel/
5+
coverage/
56
node_modules/
67

78
npm-debug.log*

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build": "astro build",
1414
"test:watch": "vitest",
1515
"preview": "astro preview",
16+
"coverage": "vitest run --coverage",
1617
"lint": "eslint src --ext .ts,.tsx,.astro",
1718
"lint:fix": "eslint src --ext .ts,.tsx,.astro --fix",
1819
"format": "prettier --write \"src/**/*.{ts,tsx,astro,css,json}\" \"*.{mjs,json}\"",
@@ -39,6 +40,7 @@
3940
"@types/canvas-confetti": "^1.9.0",
4041
"@types/node": "^25.5.2",
4142
"@vitejs/plugin-react": "^4.4.1",
43+
"@vitest/coverage-v8": "3.2.4",
4244
"eslint": "^10.2.0",
4345
"eslint-plugin-astro": "^1.6.0",
4446
"eslint-plugin-react": "^7.37.5",

pnpm-lock.yaml

Lines changed: 346 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/mascot-lines.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ export const MASCOT_LINES: Record<MascotContext, string[]> = {
105105

106106
export function pickLine(context: MascotContext, exclude?: string): string {
107107
const lines = MASCOT_LINES[context];
108-
const available = exclude ? lines.filter((l) => l !== exclude) : lines;
109-
return available[Math.floor(Math.random() * available.length)];
108+
const filtered = exclude ? lines.filter((l) => l !== exclude) : lines;
109+
const pool = filtered.length > 0 ? filtered : lines;
110+
return pool[Math.floor(Math.random() * pool.length)]!;
110111
}
111112

112113
export function contextFromPath(pathname: string): MascotContext {

src/test/browse-filters.test.tsx

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { PAGE_SIZE } from '@/lib/config';
2+
import { makeEntry } from '@/test/fixtures';
3+
import { BrowseFilters } from '@/components/browse-filters';
4+
import { describe, test, expect, vi, beforeEach } from 'vitest';
5+
import { render, screen, fireEvent } from '@testing-library/react';
6+
7+
const entries = [
8+
makeEntry({ id: 1, rarity: 'common', category: 'nerd', meaning: 'Alpha', description: 'First entry', tags: ['a'] }),
9+
makeEntry({ id: 2, rarity: 'rare', category: 'funny', meaning: 'Beta', description: 'Second entry', tags: ['b'] }),
10+
makeEntry({ id: 3, rarity: 'epic', category: 'nerd', meaning: 'Gamma', description: 'Third entry', tags: ['c'] }),
11+
12+
makeEntry({
13+
id: 4,
14+
rarity: 'legendary',
15+
category: 'funny',
16+
meaning: 'Delta',
17+
description: 'desc delta',
18+
tags: ['d'],
19+
}),
20+
];
21+
22+
describe('browse filters', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
window.scrollTo = vi.fn() as unknown as typeof window.scrollTo;
26+
});
27+
28+
test('should render all entries by default', () => {
29+
render(<BrowseFilters entries={entries} />);
30+
expect(screen.getByText('Alpha')).toBeInTheDocument();
31+
expect(screen.getByText('Beta')).toBeInTheDocument();
32+
expect(screen.getByText('Gamma')).toBeInTheDocument();
33+
expect(screen.getByText('Delta')).toBeInTheDocument();
34+
});
35+
36+
test('should show total entry count', () => {
37+
render(<BrowseFilters entries={entries} />);
38+
expect(screen.getByText('4')).toBeInTheDocument();
39+
expect(screen.getByText(/of 4 entries/)).toBeInTheDocument();
40+
});
41+
42+
test('should filter entries by meaning via search', () => {
43+
render(<BrowseFilters entries={entries} />);
44+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Alpha' } });
45+
expect(screen.getByText('Alpha')).toBeInTheDocument();
46+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
47+
});
48+
49+
test('should filter entries by description via search', () => {
50+
render(<BrowseFilters entries={entries} />);
51+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Second' } });
52+
expect(screen.getByText('Beta')).toBeInTheDocument();
53+
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
54+
});
55+
56+
test('should filter entries by tag via search', () => {
57+
render(<BrowseFilters entries={entries} />);
58+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'c' } });
59+
expect(screen.getByText('Gamma')).toBeInTheDocument();
60+
expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
61+
});
62+
63+
test('should show no results message when search matches nothing', () => {
64+
render(<BrowseFilters entries={entries} />);
65+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'xyznotfound' } });
66+
expect(screen.getByText(/No entries match your filters/)).toBeInTheDocument();
67+
});
68+
69+
test('should clear all filters when "Clear all filters" is clicked in no-results state', () => {
70+
render(<BrowseFilters entries={entries} />);
71+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'xyznotfound' } });
72+
fireEvent.click(screen.getByText('Clear all filters'));
73+
74+
expect(screen.queryByText(/No entries match your filters/)).not.toBeInTheDocument();
75+
expect(screen.getByText('Alpha')).toBeInTheDocument();
76+
});
77+
78+
test('should filter entries by category when a category button is clicked', () => {
79+
render(<BrowseFilters entries={entries} />);
80+
fireEvent.click(screen.getByRole('button', { name: /nerd/i }));
81+
82+
expect(screen.getByText('Alpha')).toBeInTheDocument();
83+
expect(screen.getByText('Gamma')).toBeInTheDocument();
84+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
85+
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
86+
});
87+
88+
test('should deselect category filter when clicked again', () => {
89+
render(<BrowseFilters entries={entries} />);
90+
const nerdBtn = screen.getByRole('button', { name: /nerd/i });
91+
92+
fireEvent.click(nerdBtn);
93+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
94+
95+
fireEvent.click(nerdBtn);
96+
expect(screen.getByText('Beta')).toBeInTheDocument();
97+
});
98+
99+
test('should filter entries by rarity when a rarity button is clicked', () => {
100+
render(<BrowseFilters entries={entries} />);
101+
fireEvent.click(screen.getByRole('button', { name: /common/i }));
102+
103+
expect(screen.getByText('Alpha')).toBeInTheDocument();
104+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
105+
expect(screen.queryByText('Gamma')).not.toBeInTheDocument();
106+
});
107+
108+
test('should combine category and rarity filters', () => {
109+
render(<BrowseFilters entries={entries} />);
110+
fireEvent.click(screen.getByRole('button', { name: /nerd/i }));
111+
fireEvent.click(screen.getByRole('button', { name: /common/i }));
112+
113+
expect(screen.getByText('Alpha')).toBeInTheDocument();
114+
expect(screen.queryByText('Gamma')).not.toBeInTheDocument();
115+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
116+
});
117+
118+
test('should show clear filters button when a filter is active', () => {
119+
render(<BrowseFilters entries={entries} />);
120+
expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
121+
122+
fireEvent.click(screen.getByRole('button', { name: /nerd/i }));
123+
expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
124+
});
125+
126+
test('should clear all active filters when "Clear filters" button is clicked', () => {
127+
render(<BrowseFilters entries={entries} />);
128+
fireEvent.click(screen.getByRole('button', { name: /nerd/i }));
129+
expect(screen.queryByText('Beta')).not.toBeInTheDocument();
130+
131+
fireEvent.click(screen.getByRole('button', { name: /clear filters/i }));
132+
expect(screen.getByText('Beta')).toBeInTheDocument();
133+
});
134+
135+
test('should sort entries alphabetically by default (a → z)', () => {
136+
render(<BrowseFilters entries={[...entries].reverse()} />);
137+
const links = screen.getAllByRole('link');
138+
const hrefs = links.map((l) => l.getAttribute('href')).filter(Boolean);
139+
140+
expect(hrefs.indexOf('/lgtm/1')).toBeLessThan(hrefs.indexOf('/lgtm/2'));
141+
expect(hrefs.indexOf('/lgtm/2')).toBeLessThan(hrefs.indexOf('/lgtm/4'));
142+
expect(hrefs.indexOf('/lgtm/4')).toBeLessThan(hrefs.indexOf('/lgtm/3'));
143+
});
144+
145+
test('should sort by rarity ascending (common first) when selected', () => {
146+
render(<BrowseFilters entries={entries} />);
147+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'rarity-asc' } });
148+
149+
const links = screen.getAllByRole('link');
150+
const textContents = links.map((l) => l.textContent ?? '');
151+
const commonIdx = textContents.findIndex((t) => t.includes('Alpha'));
152+
const legendaryIdx = textContents.findIndex((t) => t.includes('Delta'));
153+
expect(commonIdx).toBeLessThan(legendaryIdx);
154+
});
155+
156+
test('should sort by rarity descending (legendary first) when selected', () => {
157+
render(<BrowseFilters entries={entries} />);
158+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'rarity-desc' } });
159+
160+
const links = screen.getAllByRole('link');
161+
const textContents = links.map((l) => l.textContent ?? '');
162+
const legendaryIdx = textContents.findIndex((t) => t.includes('Delta'));
163+
const commonIdx = textContents.findIndex((t) => t.includes('Alpha'));
164+
expect(legendaryIdx).toBeLessThan(commonIdx);
165+
});
166+
167+
test('should sort by newest first when selected', () => {
168+
const newestEntries = [
169+
makeEntry({ id: 1, meaning: 'Oldest', created_at: '2023-01-01' }),
170+
makeEntry({ id: 2, meaning: 'Newest', created_at: '2025-06-01' }),
171+
makeEntry({ id: 3, meaning: 'Middle', created_at: '2024-03-01' }),
172+
];
173+
174+
render(<BrowseFilters entries={newestEntries} />);
175+
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'newest' } });
176+
177+
const links = screen.getAllByRole('link');
178+
const textContents = links.map((l) => l.textContent ?? '');
179+
const newestIdx = textContents.findIndex((t) => t.includes('Newest'));
180+
const oldestIdx = textContents.findIndex((t) => t.includes('Oldest'));
181+
expect(newestIdx).toBeLessThan(oldestIdx);
182+
});
183+
184+
test('should show pagination when filtered entries exceed PAGE_SIZE', () => {
185+
const manyEntries = Array.from({ length: PAGE_SIZE + 1 }, (_, i) =>
186+
makeEntry({ id: i + 1, meaning: `Entry ${i + 1}` }),
187+
);
188+
189+
render(<BrowseFilters entries={manyEntries} />);
190+
expect(screen.getByLabelText('Page 2')).toBeInTheDocument();
191+
});
192+
193+
test('should not show pagination when filtered entries fit on one page', () => {
194+
render(<BrowseFilters entries={entries} />);
195+
expect(screen.queryByLabelText('Page 2')).not.toBeInTheDocument();
196+
});
197+
198+
test('should reset to page 1 when search changes', () => {
199+
const manyEntries = Array.from({ length: PAGE_SIZE + 5 }, (_, i) =>
200+
makeEntry({ id: i + 1, meaning: `ZEntry ${i + 1}` }),
201+
);
202+
203+
render(<BrowseFilters entries={manyEntries} />);
204+
fireEvent.click(screen.getByLabelText('Page 2'));
205+
expect(screen.getByLabelText('Page 2')).toHaveAttribute('aria-current', 'page');
206+
207+
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'ZEntry 1' } });
208+
expect(screen.queryByLabelText('Page 2')).not.toBeInTheDocument();
209+
});
210+
211+
test('should reset to page 1 when category filter changes', () => {
212+
const manyEntries = [
213+
...Array.from({ length: PAGE_SIZE }, (_, i) =>
214+
makeEntry({ id: i + 1, meaning: `ZEntry ${i + 1}`, category: 'nerd' }),
215+
),
216+
217+
...Array.from({ length: 5 }, (_, i) =>
218+
makeEntry({ id: PAGE_SIZE + i + 1, meaning: `Funny ${i + 1}`, category: 'funny' }),
219+
),
220+
];
221+
222+
render(<BrowseFilters entries={manyEntries} />);
223+
fireEvent.click(screen.getByLabelText('Page 2'));
224+
expect(screen.getByLabelText('Page 2')).toHaveAttribute('aria-current', 'page');
225+
226+
fireEvent.click(screen.getByRole('button', { name: /nerd/i }));
227+
expect(screen.queryByLabelText('Page 2')).not.toBeInTheDocument();
228+
});
229+
230+
test('should call window.scrollTo when page changes', () => {
231+
const manyEntries = Array.from({ length: PAGE_SIZE + 1 }, (_, i) =>
232+
makeEntry({ id: i + 1, meaning: `Entry ${i + 1}` }),
233+
);
234+
235+
render(<BrowseFilters entries={manyEntries} />);
236+
fireEvent.click(screen.getByLabelText('Page 2'));
237+
238+
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
239+
});
240+
});

src/test/category-entries.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { PAGE_SIZE } from '@/lib/config';
2+
import { makeEntry } from '@/test/fixtures';
3+
import { render, screen } from '@testing-library/react';
4+
import { describe, test, expect, vi, beforeEach } from 'vitest';
5+
import { CategoryEntries } from '@/components/category-entries';
6+
7+
describe('category entries', () => {
8+
beforeEach(() => {
9+
vi.clearAllMocks();
10+
vi.stubGlobal('scrollTo', vi.fn());
11+
});
12+
13+
test('should render all entry links when count is below page size', () => {
14+
const entries = [makeEntry({ id: 1 }), makeEntry({ id: 2 }), makeEntry({ id: 3 })];
15+
render(<CategoryEntries entries={entries} />);
16+
const links = screen.getAllByRole('link');
17+
expect(links).toHaveLength(3);
18+
});
19+
20+
test('should render entry id as #id', () => {
21+
render(<CategoryEntries entries={[makeEntry({ id: 7 })]} />);
22+
expect(screen.getByText('#7')).toBeInTheDocument();
23+
});
24+
25+
test('should render entry meaning', () => {
26+
render(<CategoryEntries entries={[makeEntry({ meaning: 'Ship it!' })]} />);
27+
expect(screen.getByText('Ship it!')).toBeInTheDocument();
28+
});
29+
30+
test('should render description when present', () => {
31+
render(<CategoryEntries entries={[makeEntry({ description: 'A good one.' })]} />);
32+
expect(screen.getByText('A good one.')).toBeInTheDocument();
33+
});
34+
35+
test('should not render description when absent', () => {
36+
render(<CategoryEntries entries={[makeEntry({ description: undefined })]} />);
37+
expect(screen.queryByText('The original, the classic.')).not.toBeInTheDocument();
38+
});
39+
40+
test('should set href to /lgtm/:id', () => {
41+
render(<CategoryEntries entries={[makeEntry({ id: 42 })]} />);
42+
const link = screen.getByRole('link');
43+
expect(link.getAttribute('href')).toBe('/lgtm/42');
44+
});
45+
46+
test('should not render pagination when entries fit on one page', () => {
47+
const entries = Array.from({ length: PAGE_SIZE }, (_, i) => makeEntry({ id: i + 1 }));
48+
render(<CategoryEntries entries={entries} />);
49+
expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument();
50+
expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument();
51+
});
52+
53+
test('should render pagination when entries exceed PAGE_SIZE', () => {
54+
const entries = Array.from({ length: PAGE_SIZE + 1 }, (_, i) => makeEntry({ id: i + 1 }));
55+
render(<CategoryEntries entries={entries} />);
56+
expect(screen.getByLabelText('Next page')).toBeInTheDocument();
57+
});
58+
});

0 commit comments

Comments
 (0)