diff --git a/.gitignore b/.gitignore index d3b50445e9..4c0884dec8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ electron/themes electron/playwright-report server/pre-install package-lock.json - +coverage *.log core/lib/** diff --git a/joi/jest.config.js b/joi/jest.config.js new file mode 100644 index 0000000000..8543f24e30 --- /dev/null +++ b/joi/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.*'], + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jsdom', +} diff --git a/joi/jest.setup.js b/joi/jest.setup.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/joi/package.json b/joi/package.json index 3f1bd07f73..c336cce12f 100644 --- a/joi/package.json +++ b/joi/package.json @@ -21,7 +21,8 @@ "bugs": "https://github.com/codecentrum/piksel/issues", "scripts": { "dev": "rollup -c -w", - "build": "rimraf ./dist && rollup -c" + "build": "rimraf ./dist && rollup -c", + "test": "jest" }, "peerDependencies": { "class-variance-authority": "^0.7.0", @@ -38,13 +39,22 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "tailwind-merge": "^2.2.0", + "@types/jest": "^29.5.12", "autoprefixer": "10.4.16", - "tailwindcss": "^3.4.1" + "jest": "^29.7.0", + "tailwind-merge": "^2.2.0", + "tailwindcss": "^3.4.1", + "ts-jest": "^29.2.5" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@types/jest": "^29.5.12", + "jest-environment-jsdom": "^29.7.0", + "jest-transform-css": "^6.0.1", "prettier": "^3.0.3", "prettier-plugin-tailwindcss": "^0.5.6", "rollup": "^4.12.0", diff --git a/joi/src/core/Accordion/Accordion.test.tsx b/joi/src/core/Accordion/Accordion.test.tsx new file mode 100644 index 0000000000..62b575ea3b --- /dev/null +++ b/joi/src/core/Accordion/Accordion.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import '@testing-library/jest-dom' +import { render, screen, fireEvent } from '@testing-library/react' +import { Accordion, AccordionItem } from './index' + +// Mock the SCSS import +jest.mock('./styles.scss', () => ({})) + +describe('Accordion', () => { + it('renders accordion with items', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + }) + + it('expands and collapses accordion items', () => { + render( + + + Content 1 + + + ) + + const trigger = screen.getByText('Item 1') + + // Initially, content should not be visible + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + + // Click to expand + fireEvent.click(trigger) + expect(screen.getByText('Content 1')).toBeInTheDocument() + + // Click to collapse + fireEvent.click(trigger) + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + }) + + it('respects defaultValue prop', () => { + render( + + + Content 1 + + + Content 2 + + + ) + + expect(screen.queryByText('Content 1')).not.toBeInTheDocument() + expect(screen.getByText('Content 2')).toBeInTheDocument() + }) +}) diff --git a/joi/src/core/Badge/Badge.test.tsx b/joi/src/core/Badge/Badge.test.tsx new file mode 100644 index 0000000000..1d3192be73 --- /dev/null +++ b/joi/src/core/Badge/Badge.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Badge, badgeConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('@joi/core/Badge', () => { + it('renders with default props', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('badge') + expect(badge).toHaveClass('badge--primary') + expect(badge).toHaveClass('badge--medium') + expect(badge).toHaveClass('badge--solid') + }) + + it('applies custom className', () => { + render(Test Badge) + const badge = screen.getByText('Test Badge') + expect(badge).toHaveClass('custom-class') + }) + + it('renders with different themes', () => { + const themes = Object.keys(badgeConfig.variants.theme) + themes.forEach((theme) => { + render(Test Badge {theme}) + const badge = screen.getByText(`Test Badge ${theme}`) + expect(badge).toHaveClass(`badge--${theme}`) + }) + }) + + it('renders with different variants', () => { + const variants = Object.keys(badgeConfig.variants.variant) + variants.forEach((variant) => { + render(Test Badge {variant}) + const badge = screen.getByText(`Test Badge ${variant}`) + expect(badge).toHaveClass(`badge--${variant}`) + }) + }) + + it('renders with different sizes', () => { + const sizes = Object.keys(badgeConfig.variants.size) + sizes.forEach((size) => { + render(Test Badge {size}) + const badge = screen.getByText(`Test Badge ${size}`) + expect(badge).toHaveClass(`badge--${size}`) + }) + }) + + it('fails when a new theme is added without updating the test', () => { + const expectedThemes = [ + 'primary', + 'secondary', + 'warning', + 'success', + 'info', + 'destructive', + ] + const actualThemes = Object.keys(badgeConfig.variants.theme) + expect(actualThemes).toEqual(expectedThemes) + }) + + it('fails when a new variant is added without updating the test', () => { + const expectedVariant = ['solid', 'soft', 'outline'] + const actualVariants = Object.keys(badgeConfig.variants.variant) + expect(actualVariants).toEqual(expectedVariant) + }) + + it('fails when a new size is added without updating the test', () => { + const expectedSizes = ['small', 'medium', 'large'] + const actualSizes = Object.keys(badgeConfig.variants.size) + expect(actualSizes).toEqual(expectedSizes) + }) + + it('fails when a new variant CVA is added without updating the test', () => { + const expectedVariantsCVA = ['theme', 'variant', 'size'] + const actualVariant = Object.keys(badgeConfig.variants) + expect(actualVariant).toEqual(expectedVariantsCVA) + }) +}) diff --git a/joi/src/core/Badge/index.tsx b/joi/src/core/Badge/index.tsx index ffc34624f0..d9b04fd2bb 100644 --- a/joi/src/core/Badge/index.tsx +++ b/joi/src/core/Badge/index.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const badgeVariants = cva('badge', { +export const badgeConfig = { variants: { theme: { primary: 'badge--primary', @@ -28,11 +28,13 @@ const badgeVariants = cva('badge', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, }, -}) +} + +const badgeVariants = cva('badge', badgeConfig) export interface BadgeProps extends HTMLAttributes, diff --git a/joi/src/core/Button/Button.test.tsx b/joi/src/core/Button/Button.test.tsx new file mode 100644 index 0000000000..3ff76143c4 --- /dev/null +++ b/joi/src/core/Button/Button.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import { Button, buttonConfig } from './index' + +// Mock the styles +jest.mock('./styles.scss', () => ({})) + +describe('Button', () => { + it('renders with default props', () => { + render() + const button = screen.getByRole('button', { name: /click me/i }) + expect(button).toBeInTheDocument() + expect(button).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it('renders as a child component when asChild is true', () => { + render( + + ) + const link = screen.getByRole('link', { name: /link button/i }) + expect(link).toBeInTheDocument() + expect(link).toHaveClass('btn btn--primary btn--medium btn--solid') + }) + + it.each(Object.keys(buttonConfig.variants.theme))( + 'renders with theme %s', + (theme) => { + render() + const button = screen.getByRole('button', { name: /theme button/i }) + expect(button).toHaveClass(`btn btn--${theme}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.variant))( + 'renders with variant %s', + (variant) => { + render() + const button = screen.getByRole('button', { name: /variant button/i }) + expect(button).toHaveClass(`btn btn--${variant}`) + } + ) + + it.each(Object.keys(buttonConfig.variants.size))( + 'renders with size %s', + (size) => { + render() + const button = screen.getByRole('button', { name: /size button/i }) + expect(button).toHaveClass(`btn btn--${size}`) + } + ) + + it('renders with block prop', () => { + render() + const button = screen.getByRole('button', { name: /block button/i }) + expect(button).toHaveClass('btn btn--block') + }) + + it('merges custom className with generated classes', () => { + render() + const button = screen.getByRole('button', { name: /custom class button/i }) + expect(button).toHaveClass( + 'btn btn--primary btn--medium btn--solid custom-class' + ) + }) +}) diff --git a/joi/src/core/Button/index.tsx b/joi/src/core/Button/index.tsx index 014f534b02..9945eb4e97 100644 --- a/joi/src/core/Button/index.tsx +++ b/joi/src/core/Button/index.tsx @@ -7,7 +7,7 @@ import { twMerge } from 'tailwind-merge' import './styles.scss' -const buttonVariants = cva('btn', { +export const buttonConfig = { variants: { theme: { primary: 'btn--primary', @@ -30,12 +30,13 @@ const buttonVariants = cva('btn', { }, }, defaultVariants: { - theme: 'primary', - size: 'medium', - variant: 'solid', - block: false, + theme: 'primary' as const, + size: 'medium' as const, + variant: 'solid' as const, + block: false as const, }, -}) +} +const buttonVariants = cva('btn', buttonConfig) export interface ButtonProps extends ButtonHTMLAttributes, diff --git a/joi/src/hooks/useClickOutside/useClickOutside.test.tsx b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx new file mode 100644 index 0000000000..ac73b280a4 --- /dev/null +++ b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import { render, fireEvent, act } from '@testing-library/react' +import { useClickOutside } from './index' + +// Mock component to test the hook +const TestComponent: React.FC<{ onClickOutside: () => void }> = ({ + onClickOutside, +}) => { + const ref = useClickOutside(onClickOutside) + return
}>Test
+} + +describe('@joi/hooks/useClickOutside', () => { + it('should call handler when clicking outside', () => { + const handleClickOutside = jest.fn() + const { container } = render( + + ) + + act(() => { + fireEvent.mouseDown(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) + + it('should not call handler when clicking inside', () => { + const handleClickOutside = jest.fn() + const { getByText } = render( + + ) + + act(() => { + fireEvent.mouseDown(getByText('Test')) + }) + + expect(handleClickOutside).not.toHaveBeenCalled() + }) + + it('should work with custom events', () => { + const handleClickOutside = jest.fn() + const TestComponentWithCustomEvent: React.FC = () => { + const ref = useClickOutside(handleClickOutside, ['click']) + return
}>Test
+ } + + render() + + act(() => { + fireEvent.click(document.body) + }) + + expect(handleClickOutside).toHaveBeenCalledTimes(1) + }) +}) diff --git a/joi/src/hooks/useClipboard/useClipboard.test.ts b/joi/src/hooks/useClipboard/useClipboard.test.ts new file mode 100644 index 0000000000..53b4ccd27b --- /dev/null +++ b/joi/src/hooks/useClipboard/useClipboard.test.ts @@ -0,0 +1,102 @@ +import { renderHook, act } from '@testing-library/react' +import { useClipboard } from './index' + +// Mock the navigator.clipboard +const mockClipboard = { + writeText: jest.fn(() => Promise.resolve()), +} +Object.assign(navigator, { clipboard: mockClipboard }) + +describe('@joi/hooks/useClipboard', () => { + beforeEach(() => { + jest.useFakeTimers() + jest.spyOn(window, 'setTimeout') + jest.spyOn(window, 'clearTimeout') + mockClipboard.writeText.mockClear() + }) + + afterEach(() => { + jest.useRealTimers() + jest.clearAllMocks() + }) + + it('should copy text to clipboard', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Test text') + expect(result.current.copied).toBe(true) + expect(result.current.error).toBe(null) + }) + + it('should set error if clipboard write fails', async () => { + mockClipboard.writeText.mockRejectedValueOnce( + new Error('Clipboard write failed') + ) + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe('Clipboard write failed') + }) + + it('should set error if clipboard is not supported', async () => { + const originalClipboard = navigator.clipboard + // @ts-ignore + delete navigator.clipboard + + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error?.message).toBe( + 'useClipboard: navigator.clipboard is not supported' + ) + + // Restore clipboard support + Object.assign(navigator, { clipboard: originalClipboard }) + }) + + it('should reset copied state after timeout', async () => { + const { result } = renderHook(() => useClipboard({ timeout: 1000 })) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(result.current.copied).toBe(false) + }) + + it('should reset state when reset is called', async () => { + const { result } = renderHook(() => useClipboard()) + + await act(async () => { + result.current.copy('Test text') + }) + + expect(result.current.copied).toBe(true) + + act(() => { + result.current.reset() + }) + + expect(result.current.copied).toBe(false) + expect(result.current.error).toBe(null) + }) +}) diff --git a/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts new file mode 100644 index 0000000000..5813bd41d8 --- /dev/null +++ b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act } from '@testing-library/react' +import { useMediaQuery } from './index' + +describe('@joi/hooks/useMediaQuery', () => { + const matchMediaMock = jest.fn() + + beforeAll(() => { + window.matchMedia = matchMediaMock + }) + + afterEach(() => { + matchMediaMock.mockClear() + }) + + it('should return initial value when getInitialValueInEffect is true', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addListener: jest.fn(), + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => + useMediaQuery('(min-width: 768px)', true, { + getInitialValueInEffect: true, + }) + ) + + expect(result.current).toBe(true) + }) + + it('should return correct value based on media query', () => { + matchMediaMock.mockImplementation(() => ({ + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(true) + }) + + it('should update value when media query changes', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addEventListener: (_, cb) => { + listener = cb + }, + removeEventListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) + + it('should handle older browsers without addEventListener', () => { + let listener: ((event: { matches: boolean }) => void) | null = null + + matchMediaMock.mockImplementation(() => ({ + matches: false, + addListener: (cb) => { + listener = cb + }, + removeListener: jest.fn(), + })) + + const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')) + + expect(result.current).toBe(false) + + act(() => { + if (listener) { + listener({ matches: true }) + } + }) + + expect(result.current).toBe(true) + }) +}) diff --git a/joi/src/hooks/useOs/useOs.test.ts b/joi/src/hooks/useOs/useOs.test.ts new file mode 100644 index 0000000000..037640b5e1 --- /dev/null +++ b/joi/src/hooks/useOs/useOs.test.ts @@ -0,0 +1,39 @@ +import { renderHook } from '@testing-library/react' +import { useOs } from './index' + +const platforms = { + windows: [ + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', + ], + macos: [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0', + ], + linux: [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', + ], + ios: [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Mobile/15E148 Safari/604.1', + ], + android: [ + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36', + ], + undetermined: ['UNKNOWN'], +} as const + +describe('@joi/hooks/useOS', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + Object.entries(platforms).forEach(([os, userAgents]) => { + it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValueOnce(userAgent) + + const { result } = renderHook(() => useOs()) + + expect(result.current).toBe(os) + }) + }) +}) diff --git a/joi/src/hooks/usePageLeave/usePageLeave.test.ts b/joi/src/hooks/usePageLeave/usePageLeave.test.ts new file mode 100644 index 0000000000..093ae31c10 --- /dev/null +++ b/joi/src/hooks/usePageLeave/usePageLeave.test.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react' +import { fireEvent } from '@testing-library/react' +import { usePageLeave } from './index' + +describe('@joi/hooks/usePageLeave', () => { + it('should call onPageLeave when mouse leaves the document', () => { + const onPageLeaveMock = jest.fn() + const { result } = renderHook(() => usePageLeave(onPageLeaveMock)) + + fireEvent.mouseLeave(document.documentElement) + + expect(onPageLeaveMock).toHaveBeenCalledTimes(1) + }) + + it('should remove event listener on unmount', () => { + const onPageLeaveMock = jest.fn() + const removeEventListenerSpy = jest.spyOn( + document.documentElement, + 'removeEventListener' + ) + + const { unmount } = renderHook(() => usePageLeave(onPageLeaveMock)) + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mouseleave', + expect.any(Function) + ) + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/joi/src/hooks/useTextSelection/useTextSelection.test.ts b/joi/src/hooks/useTextSelection/useTextSelection.test.ts new file mode 100644 index 0000000000..26efa23e71 --- /dev/null +++ b/joi/src/hooks/useTextSelection/useTextSelection.test.ts @@ -0,0 +1,56 @@ +import { renderHook, act } from '@testing-library/react' +import { useTextSelection } from './index' + +describe('@joi/hooks/useTextSelection', () => { + let mockSelection: Selection + + beforeEach(() => { + mockSelection = { + toString: jest.fn(), + removeAllRanges: jest.fn(), + addRange: jest.fn(), + } as unknown as Selection + + jest.spyOn(document, 'getSelection').mockReturnValue(mockSelection) + jest.spyOn(document, 'addEventListener') + jest.spyOn(document, 'removeEventListener') + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should return the initial selection', () => { + const { result } = renderHook(() => useTextSelection()) + expect(result.current).toBe(mockSelection) + }) + + it('should add and remove event listener', () => { + const { unmount } = renderHook(() => useTextSelection()) + + expect(document.addEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + + unmount() + + expect(document.removeEventListener).toHaveBeenCalledWith( + 'selectionchange', + expect.any(Function) + ) + }) + + it('should update selection when selectionchange event is triggered', () => { + const { result } = renderHook(() => useTextSelection()) + + const newMockSelection = { toString: jest.fn() } as unknown as Selection + jest.spyOn(document, 'getSelection').mockReturnValue(newMockSelection) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + + expect(result.current).toBe(newMockSelection) + }) +}) diff --git a/joi/tsconfig.json b/joi/tsconfig.json index f72e8151e5..25e2ee66fb 100644 --- a/joi/tsconfig.json +++ b/joi/tsconfig.json @@ -3,6 +3,7 @@ "target": "esnext", "declaration": true, "declarationDir": "dist/types", + "types": ["jest", "@testing-library/jest-dom"], "module": "esnext", "lib": ["es6", "dom", "es2016", "es2017"], "sourceMap": true,