diff options
| author | Bertrand Yuan <189593334+bertyuan@users.noreply.github.com> | 2026-03-26 00:19:31 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-26 00:19:31 +0800 |
| commit | f247a8c4a863ec430f4a705b5c493d652c8429bd (patch) | |
| tree | 71d0985970984c105582f6e3c370b254f38e9bbe /src/components | |
| parent | f7a02fe0e112cf108fc5f22872f1efc077e99fe8 (diff) | |
| parent | cd3c4bc89c169616b38bdb7443bb4eb7571b020c (diff) | |
Merge pull request #12 from bertyuan/fix-vitestv1.1
Fix vitest
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/json-ld.tsx | 2 | ||||
| -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/button.test.tsx | 15 | ||||
| -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 |
18 files changed, 600 insertions, 5 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..6a81414 --- /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} />); + 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/json-ld.tsx b/src/components/json-ld.tsx index 58cb0ba..bbfad33 100644 --- a/src/components/json-ld.tsx +++ b/src/components/json-ld.tsx @@ -4,7 +4,7 @@ import { baseUrl } from '@/lib/constants'; import type { BlogPost } from '@/lib/payload-posts'; import type { BlogPosting, BreadcrumbList, Graph } from 'schema-dts'; -export const PostJsonLd = ({ post }: { post: BlogPost }) => { +export const PostJsonLd = ({ post }: { post: BlogPost | null | undefined }) => { if (!post) { return null; } 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/button.test.tsx b/src/components/ui/button.test.tsx index 314e9bf..f8bd3a9 100644 --- a/src/components/ui/button.test.tsx +++ b/src/components/ui/button.test.tsx @@ -14,9 +14,16 @@ describe('Button', () => { // Test buttons with different variants test('renders button with different variants', () => { - const variants = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']; + const variants = [ + 'default', + 'destructive', + 'outline', + 'secondary', + 'ghost', + 'link', + ] as const; variants.forEach((variant) => { - render(<Button variant={variant as any}>{variant} Variant</Button>); + render(<Button variant={variant}>{variant} Variant</Button>); const button = screen.getByText(`${variant} Variant`); expect(button).toBeInTheDocument(); }); @@ -24,9 +31,9 @@ describe('Button', () => { // Test buttons with different sizes test('renders button with different sizes', () => { - const sizes = ['default', 'sm', 'lg', 'icon']; + const sizes = ['default', 'sm', 'lg', 'icon'] as const; sizes.forEach((size) => { - render(<Button size={size as any}>{size} Size</Button>); + render(<Button size={size}>{size} Size</Button>); const button = screen.getByText(`${size} Size`); expect(button).toBeInTheDocument(); }); 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'); + }); +}); + |
