summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/active-link.test.tsx57
-rw-r--r--src/components/inline-link.test.tsx41
-rw-r--r--src/components/json-ld.test.tsx66
-rw-r--r--src/components/numbered-pagination.test.tsx64
-rw-r--r--src/components/rich-text/node-format.test.ts40
-rw-r--r--src/components/section.test.tsx19
-rw-r--r--src/components/tags/tag-card.test.tsx36
-rw-r--r--src/components/tailwind-indicator.test.tsx30
-rw-r--r--src/components/theme-provider.test.tsx23
-rw-r--r--src/components/ui/alert.test.tsx30
-rw-r--r--src/components/ui/button-variants.test.ts28
-rw-r--r--src/components/ui/card.test.tsx40
-rw-r--r--src/components/ui/input.test.tsx22
-rw-r--r--src/components/ui/label.test.tsx15
-rw-r--r--src/components/ui/pagination.test.tsx61
-rw-r--r--src/components/ui/skeleton.test.tsx16
16 files changed, 588 insertions, 0 deletions
diff --git a/src/components/active-link.test.tsx b/src/components/active-link.test.tsx
new file mode 100644
index 0000000..0c603cb
--- /dev/null
+++ b/src/components/active-link.test.tsx
@@ -0,0 +1,57 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+import { ActiveLink } from './active-link';
+import { usePathname } from 'next/navigation';
+
+vi.mock('next/navigation', () => ({
+ usePathname: vi.fn(),
+}));
+
+vi.mock('next/link', () => ({
+ default: ({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: ReactNode;
+ }) => (
+ <a href={href} {...props}>
+ {children}
+ </a>
+ ),
+}));
+
+describe('ActiveLink', () => {
+ test('applies active styles when pathname matches href', () => {
+ vi.mocked(usePathname).mockReturnValue('/posts');
+
+ render(<ActiveLink href='/posts'>Posts</ActiveLink>);
+
+ const link = screen.getByRole('link', { name: 'Posts' });
+ expect(link).toHaveClass('font-medium');
+ });
+
+ test('does not apply active styles for non-matching paths', () => {
+ vi.mocked(usePathname).mockReturnValue('/tags');
+
+ render(<ActiveLink href='/posts'>Posts</ActiveLink>);
+
+ const link = screen.getByRole('link', { name: 'Posts' });
+ expect(link).not.toHaveClass('font-medium');
+ });
+
+ test('supports nested path matching when nested prop is enabled', () => {
+ vi.mocked(usePathname).mockReturnValue('/posts/testing-vitest');
+
+ render(
+ <ActiveLink href='/posts' nested>
+ Posts
+ </ActiveLink>,
+ );
+
+ const link = screen.getByRole('link', { name: 'Posts' });
+ expect(link).toHaveClass('font-medium');
+ });
+});
diff --git a/src/components/inline-link.test.tsx b/src/components/inline-link.test.tsx
new file mode 100644
index 0000000..ad9813d
--- /dev/null
+++ b/src/components/inline-link.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+import { InlineLink } from './inline-link';
+
+vi.mock('next/link', () => ({
+ default: ({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: ReactNode;
+ }) => (
+ <a href={href} {...props}>
+ {children}
+ </a>
+ ),
+}));
+
+describe('InlineLink', () => {
+ test('renders as a normal link by default', () => {
+ render(<InlineLink href='/posts'>Posts</InlineLink>);
+
+ const link = screen.getByRole('link', { name: 'Posts' });
+ expect(link).toHaveAttribute('href', '/posts');
+ expect(link).not.toHaveAttribute('target', '_blank');
+ });
+
+ test('sets target blank when blank prop is true', () => {
+ render(
+ <InlineLink href='https://example.com' blank>
+ External
+ </InlineLink>,
+ );
+
+ const link = screen.getByRole('link', { name: 'External' });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+});
diff --git a/src/components/json-ld.test.tsx b/src/components/json-ld.test.tsx
new file mode 100644
index 0000000..c96e5c3
--- /dev/null
+++ b/src/components/json-ld.test.tsx
@@ -0,0 +1,66 @@
+import { render } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+vi.mock('@/app/(main)/layout.config', () => ({
+ title: 'Blog',
+ owner: 'Test Owner',
+}));
+
+vi.mock('@/lib/constants', () => ({
+ baseUrl: new URL('http://localhost:3000'),
+}));
+
+import { PostJsonLd, TagJsonLd } from './json-ld';
+
+describe('json-ld components', () => {
+ test('renders post json-ld with blog posting and breadcrumb graph', () => {
+ const { container } = render(
+ <PostJsonLd
+ post={{
+ id: 1,
+ title: 'Testing Post',
+ slug: 'testing-post',
+ url: '/posts/testing-post',
+ description: 'A post for tests',
+ content: null,
+ image: '/cover.png',
+ author: 'Alice',
+ tags: ['testing'],
+ date: new Date('2025-01-01T00:00:00.000Z'),
+ createdAt: new Date('2025-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2025-01-02T00:00:00.000Z'),
+ }}
+ />,
+ );
+
+ const script = container.querySelector('script[type="application/ld+json"]');
+ expect(script).toBeInTheDocument();
+
+ const payload = JSON.parse(script?.innerHTML ?? '{}');
+ expect(payload['@context']).toBe('https://schema.org');
+ expect(payload['@graph']).toHaveLength(2);
+ expect(payload['@graph'][0]['@type']).toBe('BlogPosting');
+ expect(payload['@graph'][0].mainEntityOfPage['@id']).toBe(
+ 'http://localhost:3000/posts/testing-post',
+ );
+ });
+
+ test('returns null when post is not provided', () => {
+ const { container } = render(<PostJsonLd post={null as unknown as any} />);
+ expect(container.firstChild).toBeNull();
+ });
+
+ test('renders tag json-ld with tag-specific breadcrumb item', () => {
+ const { container } = render(<TagJsonLd tag='nextjs' />);
+
+ const script = container.querySelector('script[type="application/ld+json"]');
+ expect(script).toBeInTheDocument();
+
+ const payload = JSON.parse(script?.innerHTML ?? '{}');
+ expect(payload['@graph']).toHaveLength(1);
+ expect(payload['@graph'][0]['@type']).toBe('BreadcrumbList');
+ expect(payload['@graph'][0].itemListElement[2].item).toBe(
+ 'http://localhost:3000/tags/nextjs',
+ );
+ });
+});
diff --git a/src/components/numbered-pagination.test.tsx b/src/components/numbered-pagination.test.tsx
new file mode 100644
index 0000000..d99ab46
--- /dev/null
+++ b/src/components/numbered-pagination.test.tsx
@@ -0,0 +1,64 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+import { NumberedPagination } from './numbered-pagination';
+
+describe('NumberedPagination', () => {
+ test('renders page controls and current page', () => {
+ render(
+ <NumberedPagination
+ currentPage={5}
+ totalPages={10}
+ paginationItemsToDisplay={5}
+ onPageChange={vi.fn()}
+ />,
+ );
+
+ expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: '5' })).toHaveAttribute('aria-current');
+ expect(screen.getAllByText('...')).toHaveLength(2);
+ });
+
+ test('calls onPageChange when selecting a valid page', () => {
+ const onPageChange = vi.fn();
+ render(
+ <NumberedPagination
+ currentPage={2}
+ totalPages={5}
+ paginationItemsToDisplay={5}
+ onPageChange={onPageChange}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('link', { name: '3' }));
+ expect(onPageChange).toHaveBeenCalledWith(3);
+ });
+
+ test('does not call onPageChange for out-of-range previous/next actions', () => {
+ const onPageChange = vi.fn();
+ const { rerender } = render(
+ <NumberedPagination
+ currentPage={1}
+ totalPages={3}
+ paginationItemsToDisplay={5}
+ onPageChange={onPageChange}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('link', { name: 'Go to previous page' }));
+ expect(onPageChange).not.toHaveBeenCalled();
+
+ rerender(
+ <NumberedPagination
+ currentPage={3}
+ totalPages={3}
+ paginationItemsToDisplay={5}
+ onPageChange={onPageChange}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('link', { name: 'Go to next page' }));
+ expect(onPageChange).not.toHaveBeenCalled();
+ });
+});
+
diff --git a/src/components/rich-text/node-format.test.ts b/src/components/rich-text/node-format.test.ts
new file mode 100644
index 0000000..476a0d9
--- /dev/null
+++ b/src/components/rich-text/node-format.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from 'vitest';
+import {
+ IS_BOLD,
+ IS_CODE,
+ IS_HIGHLIGHT,
+ IS_ITALIC,
+ IS_STRIKETHROUGH,
+ IS_SUBSCRIPT,
+ IS_SUPERSCRIPT,
+ IS_UNDERLINE,
+} from './node-format';
+
+describe('node format bit flags', () => {
+ test('uses power-of-two values for unique bitmasks', () => {
+ const flags = [
+ IS_BOLD,
+ IS_ITALIC,
+ IS_STRIKETHROUGH,
+ IS_UNDERLINE,
+ IS_CODE,
+ IS_SUBSCRIPT,
+ IS_SUPERSCRIPT,
+ IS_HIGHLIGHT,
+ ];
+
+ for (const flag of flags) {
+ expect((flag & (flag - 1)) === 0).toBe(true);
+ }
+ });
+
+ test('can combine and detect individual flags with bitwise operations', () => {
+ const format = IS_BOLD | IS_ITALIC | IS_CODE;
+
+ expect((format & IS_BOLD) !== 0).toBe(true);
+ expect((format & IS_ITALIC) !== 0).toBe(true);
+ expect((format & IS_CODE) !== 0).toBe(true);
+ expect((format & IS_UNDERLINE) !== 0).toBe(false);
+ });
+});
+
diff --git a/src/components/section.test.tsx b/src/components/section.test.tsx
new file mode 100644
index 0000000..18002ae
--- /dev/null
+++ b/src/components/section.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { Section } from './section';
+
+describe('Section', () => {
+ test('renders children and forwards section attributes', () => {
+ render(
+ <Section data-testid='section' sectionClassName='py-20' className='px-4'>
+ <div>Body</div>
+ </Section>,
+ );
+
+ const section = screen.getByTestId('section');
+ expect(section.tagName).toBe('SECTION');
+ expect(section.className).toContain('py-20');
+ expect(screen.getByText('Body')).toBeInTheDocument();
+ });
+});
+
diff --git a/src/components/tags/tag-card.test.tsx b/src/components/tags/tag-card.test.tsx
new file mode 100644
index 0000000..645441b
--- /dev/null
+++ b/src/components/tags/tag-card.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+import { TagCard } from './tag-card';
+
+vi.mock('next/link', () => ({
+ default: ({
+ href,
+ children,
+ ...props
+ }: {
+ href: string;
+ children: ReactNode;
+ }) => (
+ <a href={href} {...props}>
+ {children}
+ </a>
+ ),
+}));
+
+describe('TagCard', () => {
+ test('renders a tag link with computed tag url', () => {
+ render(<TagCard name='react' />);
+
+ const link = screen.getByRole('link', { name: /react/i });
+ expect(link).toHaveAttribute('href', '/tags/react');
+ });
+
+ test('renders count only when displayCount is true and count exists', () => {
+ const { rerender } = render(<TagCard name='nextjs' displayCount count={12} />);
+ expect(screen.getByText('(12)')).toBeInTheDocument();
+
+ rerender(<TagCard name='nextjs' count={12} />);
+ expect(screen.queryByText('(12)')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/tailwind-indicator.test.tsx b/src/components/tailwind-indicator.test.tsx
new file mode 100644
index 0000000..4244ef6
--- /dev/null
+++ b/src/components/tailwind-indicator.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import { afterAll, beforeEach, describe, expect, test } from 'vitest';
+import { TailwindIndicator } from './tailwind-indicator';
+
+const originalEnv = process.env;
+
+describe('TailwindIndicator', () => {
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ afterAll(() => {
+ process.env = originalEnv;
+ });
+
+ test('renders in non-production environments', () => {
+ process.env.NODE_ENV = 'test';
+ render(<TailwindIndicator />);
+
+ expect(screen.getByText('xs')).toBeInTheDocument();
+ });
+
+ test('returns null in production', () => {
+ process.env.NODE_ENV = 'production';
+ const { container } = render(<TailwindIndicator />);
+
+ expect(container.firstChild).toBeNull();
+ });
+});
+
diff --git a/src/components/theme-provider.test.tsx b/src/components/theme-provider.test.tsx
new file mode 100644
index 0000000..0ac54e1
--- /dev/null
+++ b/src/components/theme-provider.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+import { ThemeProvider } from './theme-provider';
+
+vi.mock('next-themes', () => ({
+ ThemeProvider: ({ children }: { children: ReactNode }) => (
+ <div data-testid='next-themes-provider'>{children}</div>
+ ),
+}));
+
+describe('ThemeProvider', () => {
+ test('renders children through next-themes provider', () => {
+ render(
+ <ThemeProvider attribute='class'>
+ <span>content</span>
+ </ThemeProvider>,
+ );
+
+ expect(screen.getByTestId('next-themes-provider')).toBeInTheDocument();
+ expect(screen.getByText('content')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ui/alert.test.tsx b/src/components/ui/alert.test.tsx
new file mode 100644
index 0000000..ded0356
--- /dev/null
+++ b/src/components/ui/alert.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { Alert, AlertDescription, AlertTitle } from './alert';
+
+describe('Alert', () => {
+ test('renders with role and slot attributes', () => {
+ render(
+ <Alert>
+ <AlertTitle>Heads up</AlertTitle>
+ <AlertDescription>Something happened.</AlertDescription>
+ </Alert>,
+ );
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveAttribute('data-slot', 'alert');
+ expect(screen.getByText('Heads up')).toHaveAttribute('data-slot', 'alert-title');
+ expect(screen.getByText('Something happened.')).toHaveAttribute(
+ 'data-slot',
+ 'alert-description',
+ );
+ });
+
+ test('applies destructive variant classes', () => {
+ render(<Alert variant='destructive'>Danger</Alert>);
+
+ const alert = screen.getByRole('alert');
+ expect(alert.className).toContain('text-destructive');
+ });
+});
+
diff --git a/src/components/ui/button-variants.test.ts b/src/components/ui/button-variants.test.ts
new file mode 100644
index 0000000..5090703
--- /dev/null
+++ b/src/components/ui/button-variants.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from 'vitest';
+import { buttonVariants } from './button';
+
+describe('buttonVariants', () => {
+ test('returns default variant and size classes when no args are provided', () => {
+ const classes = buttonVariants();
+
+ expect(classes).toContain('bg-primary');
+ expect(classes).toContain('h-9');
+ });
+
+ test('returns destructive and icon classes for explicit options', () => {
+ const classes = buttonVariants({
+ variant: 'destructive',
+ size: 'icon',
+ });
+
+ expect(classes).toContain('bg-destructive');
+ expect(classes).toContain('size-9');
+ });
+
+ test('includes caller-provided className', () => {
+ const classes = buttonVariants({ className: 'w-full' });
+
+ expect(classes).toContain('w-full');
+ });
+});
+
diff --git a/src/components/ui/card.test.tsx b/src/components/ui/card.test.tsx
new file mode 100644
index 0000000..aba3d00
--- /dev/null
+++ b/src/components/ui/card.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from './card';
+
+describe('Card', () => {
+ test('renders all card slots', () => {
+ render(
+ <Card>
+ <CardHeader>
+ <CardTitle>Card Title</CardTitle>
+ <CardDescription>Card Description</CardDescription>
+ <CardAction>Action</CardAction>
+ </CardHeader>
+ <CardContent>Content</CardContent>
+ <CardFooter>Footer</CardFooter>
+ </Card>,
+ );
+
+ expect(screen.getByText('Card Title')).toHaveAttribute(
+ 'data-slot',
+ 'card-title',
+ );
+ expect(screen.getByText('Card Description')).toHaveAttribute(
+ 'data-slot',
+ 'card-description',
+ );
+ expect(screen.getByText('Action')).toHaveAttribute('data-slot', 'card-action');
+ expect(screen.getByText('Content')).toHaveAttribute('data-slot', 'card-content');
+ expect(screen.getByText('Footer')).toHaveAttribute('data-slot', 'card-footer');
+ });
+});
+
diff --git a/src/components/ui/input.test.tsx b/src/components/ui/input.test.tsx
new file mode 100644
index 0000000..aabe76f
--- /dev/null
+++ b/src/components/ui/input.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { Input } from './input';
+
+describe('Input', () => {
+ test('renders an input with the expected slot attribute', () => {
+ render(<Input aria-label='Email' />);
+
+ const input = screen.getByLabelText('Email');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute('data-slot', 'input');
+ });
+
+ test('supports input type and disabled props', () => {
+ render(<Input aria-label='Password' type='password' disabled />);
+
+ const input = screen.getByLabelText('Password');
+ expect(input).toHaveAttribute('type', 'password');
+ expect(input).toBeDisabled();
+ });
+});
+
diff --git a/src/components/ui/label.test.tsx b/src/components/ui/label.test.tsx
new file mode 100644
index 0000000..08031b6
--- /dev/null
+++ b/src/components/ui/label.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { Label } from './label';
+
+describe('Label', () => {
+ test('renders label text and exposes data-slot', () => {
+ render(<Label htmlFor='email'>Email</Label>);
+
+ const label = screen.getByText('Email');
+ expect(label).toBeInTheDocument();
+ expect(label).toHaveAttribute('for', 'email');
+ expect(label).toHaveAttribute('data-slot', 'label');
+ });
+});
+
diff --git a/src/components/ui/pagination.test.tsx b/src/components/ui/pagination.test.tsx
new file mode 100644
index 0000000..77309b8
--- /dev/null
+++ b/src/components/ui/pagination.test.tsx
@@ -0,0 +1,61 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from './pagination';
+
+describe('Pagination UI', () => {
+ test('renders pagination container and list slots', () => {
+ render(
+ <Pagination>
+ <PaginationContent>
+ <PaginationItem>
+ <PaginationLink href='#'>1</PaginationLink>
+ </PaginationItem>
+ </PaginationContent>
+ </Pagination>,
+ );
+
+ const nav = screen.getByLabelText('pagination');
+ expect(nav).toHaveAttribute('data-slot', 'pagination');
+ expect(screen.getByRole('link', { name: '1' })).toHaveAttribute(
+ 'data-slot',
+ 'pagination-link',
+ );
+ });
+
+ test('sets active page attributes', () => {
+ render(
+ <PaginationLink href='#' isActive>
+ 2
+ </PaginationLink>,
+ );
+
+ const link = screen.getByRole('link', { name: '2' });
+ expect(link).toHaveAttribute('aria-current', 'page');
+ expect(link).toHaveAttribute('data-active', 'true');
+ });
+
+ test('renders previous, next, and ellipsis affordances', () => {
+ render(
+ <div>
+ <PaginationPrevious href='#' />
+ <PaginationNext href='#' />
+ <PaginationEllipsis />
+ </div>,
+ );
+
+ expect(
+ screen.getByRole('link', { name: 'Go to previous page' }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument();
+ expect(screen.getByText('More pages')).toBeInTheDocument();
+ });
+});
+
diff --git a/src/components/ui/skeleton.test.tsx b/src/components/ui/skeleton.test.tsx
new file mode 100644
index 0000000..4459d77
--- /dev/null
+++ b/src/components/ui/skeleton.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { Skeleton } from './skeleton';
+
+describe('Skeleton', () => {
+ test('renders with default and custom classes', () => {
+ render(<Skeleton data-testid='skeleton' className='h-4 w-20' />);
+
+ const skeleton = screen.getByTestId('skeleton');
+ expect(skeleton).toHaveAttribute('data-slot', 'skeleton');
+ expect(skeleton.className).toContain('animate-pulse');
+ expect(skeleton.className).toContain('h-4');
+ expect(skeleton.className).toContain('w-20');
+ });
+});
+