Sifa professional network frontend (Next.js, React, TailwindCSS)
sifa.id/
1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { describe, expect, it, vi } from 'vitest';
4
5import type { ActiveApp } from '@/lib/types';
6
7vi.mock('next-intl', () => ({
8 useTranslations: () => (key: string, values?: Record<string, unknown>) => {
9 if (key === 'activeOn') return `Active on ${values?.app}`;
10 if (key === 'moreApps') return `+${values?.count} more`;
11 if (key === 'label') return 'Active apps';
12 return key;
13 },
14}));
15
16const mockApps: ActiveApp[] = [
17 { id: 'bluesky', name: 'Bluesky network', category: 'social', recentCount: 42 },
18 { id: 'tangled', name: 'Tangled', category: 'dev', recentCount: 15 },
19 { id: 'whitewind', name: 'Whitewind', category: 'blog', recentCount: 8 },
20];
21
22const manyApps: ActiveApp[] = [
23 { id: 'bluesky', name: 'Bluesky network', category: 'social', recentCount: 42 },
24 { id: 'tangled', name: 'Tangled', category: 'dev', recentCount: 15 },
25 { id: 'whitewind', name: 'Whitewind', category: 'blog', recentCount: 8 },
26 { id: 'frontpage', name: 'Frontpage', category: 'social', recentCount: 5 },
27 { id: 'smokesignal', name: 'Events', category: 'events', recentCount: 3 },
28 { id: 'picosky', name: 'Picosky', category: 'social', recentCount: 2 },
29 { id: 'linkat', name: 'Linkat', category: 'links', recentCount: 1 },
30];
31
32describe('ActivityIndicators', () => {
33 // Lazy import so the mock is in place before module loads
34 async function loadComponent() {
35 const mod = await import('./activity-indicators');
36 return mod.ActivityIndicators;
37 }
38
39 it('renders pills for each active app', { timeout: 60_000 }, async () => {
40 const ActivityIndicators = await loadComponent();
41 render(<ActivityIndicators apps={mockApps} />);
42
43 expect(screen.getByText('Bluesky network')).toBeDefined();
44 expect(screen.getByText('Tangled')).toBeDefined();
45 expect(screen.getByText('Whitewind')).toBeDefined();
46 });
47
48 it('renders nothing when apps is empty', async () => {
49 const ActivityIndicators = await loadComponent();
50 const { container } = render(<ActivityIndicators apps={[]} />);
51
52 expect(container.innerHTML).toBe('');
53 });
54
55 it('does not show counts in pills', async () => {
56 const ActivityIndicators = await loadComponent();
57 render(<ActivityIndicators apps={mockApps} />);
58
59 expect(screen.queryByText('42')).toBeNull();
60 expect(screen.queryByText('15')).toBeNull();
61 expect(screen.queryByText('8')).toBeNull();
62 });
63
64 it('has accessible labels on pills', async () => {
65 const ActivityIndicators = await loadComponent();
66 render(<ActivityIndicators apps={mockApps} />);
67
68 expect(screen.getByLabelText('Active on Bluesky network')).toBeDefined();
69 expect(screen.getByLabelText('Active on Tangled')).toBeDefined();
70 expect(screen.getByLabelText('Active on Whitewind')).toBeDefined();
71 });
72
73 it('shows overflow button when more than maxVisible apps', async () => {
74 const ActivityIndicators = await loadComponent();
75 render(<ActivityIndicators apps={manyApps} maxVisible={5} />);
76
77 const moreButton = screen.getByRole('button', { name: /\+2 more/i });
78 expect(moreButton).toBeDefined();
79 expect(moreButton.getAttribute('aria-expanded')).toBe('false');
80 });
81
82 it('expands overflow on click', async () => {
83 const user = userEvent.setup();
84 const ActivityIndicators = await loadComponent();
85 render(<ActivityIndicators apps={manyApps} maxVisible={5} />);
86
87 // Before expand: overflow apps should not be visible
88 expect(screen.queryByText('Picosky')).toBeNull();
89 expect(screen.queryByText('Linkat')).toBeNull();
90
91 const moreButton = screen.getByRole('button', { name: /\+2 more/i });
92 await user.click(moreButton);
93
94 // After expand: all apps visible
95 expect(screen.getByText('Picosky')).toBeDefined();
96 expect(screen.getByText('Linkat')).toBeDefined();
97 });
98
99 it('calls onFilter when a pill is clicked', async () => {
100 const user = userEvent.setup();
101 const onFilter = vi.fn();
102 const ActivityIndicators = await loadComponent();
103 render(<ActivityIndicators apps={mockApps} onFilter={onFilter} />);
104
105 await user.click(screen.getByLabelText('Active on Bluesky network'));
106 expect(onFilter).toHaveBeenCalledWith('bluesky');
107 });
108
109 it('toggles filter off when active pill is clicked again', async () => {
110 const user = userEvent.setup();
111 const onFilter = vi.fn();
112 const ActivityIndicators = await loadComponent();
113 render(<ActivityIndicators apps={mockApps} activeFilter="bluesky" onFilter={onFilter} />);
114
115 await user.click(screen.getByLabelText('Active on Bluesky network'));
116 expect(onFilter).toHaveBeenCalledWith(null);
117 });
118
119 it('sets aria-pressed on active filter pill', async () => {
120 const onFilter = vi.fn();
121 const ActivityIndicators = await loadComponent();
122 render(<ActivityIndicators apps={mockApps} activeFilter="bluesky" onFilter={onFilter} />);
123
124 const blueskyPill = screen.getByLabelText('Active on Bluesky network');
125 expect(blueskyPill.getAttribute('aria-pressed')).toBe('true');
126
127 const tangledPill = screen.getByLabelText('Active on Tangled');
128 expect(tangledPill.getAttribute('aria-pressed')).toBe('false');
129 });
130
131 it('renders spans (not buttons) when onFilter is not provided', async () => {
132 const ActivityIndicators = await loadComponent();
133 render(<ActivityIndicators apps={mockApps} />);
134
135 const blueskyPill = screen.getByLabelText('Active on Bluesky network');
136 expect(blueskyPill.tagName).toBe('SPAN');
137
138 // No buttons for pills (only possible overflow button if needed)
139 const buttons = screen.queryAllByRole('button');
140 expect(buttons).toHaveLength(0);
141 });
142});