diff options
| author | Bertrand Yuan <noreply@bertyuan.com> | 2026-03-26 00:02:16 +0800 |
|---|---|---|
| committer | Bertrand Yuan <noreply@bertyuan.com> | 2026-03-26 00:02:16 +0800 |
| commit | 8a6a6712e7554f110b5ef951f270d88fd010e040 (patch) | |
| tree | 12cb86b1ede55e15600ef7f139ef7ec91b9fa8a1 /src/components | |
| parent | f7a02fe0e112cf108fc5f22872f1efc077e99fe8 (diff) | |
add more tests
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/active-link.test.tsx | 57 | ||||
| -rw-r--r-- | src/components/inline-link.test.tsx | 41 | ||||
| -rw-r--r-- | src/components/json-ld.test.tsx | 66 | ||||
| -rw-r--r-- | src/components/numbered-pagination.test.tsx | 64 | ||||
| -rw-r--r-- | src/components/rich-text/node-format.test.ts | 40 | ||||
| -rw-r--r-- | src/components/section.test.tsx | 19 | ||||
| -rw-r--r-- | src/components/tags/tag-card.test.tsx | 36 | ||||
| -rw-r--r-- | src/components/tailwind-indicator.test.tsx | 30 | ||||
| -rw-r--r-- | src/components/theme-provider.test.tsx | 23 | ||||
| -rw-r--r-- | src/components/ui/alert.test.tsx | 30 | ||||
| -rw-r--r-- | src/components/ui/button-variants.test.ts | 28 | ||||
| -rw-r--r-- | src/components/ui/card.test.tsx | 40 | ||||
| -rw-r--r-- | src/components/ui/input.test.tsx | 22 | ||||
| -rw-r--r-- | src/components/ui/label.test.tsx | 15 | ||||
| -rw-r--r-- | src/components/ui/pagination.test.tsx | 61 | ||||
| -rw-r--r-- | src/components/ui/skeleton.test.tsx | 16 |
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'); + }); +}); + |
