diff --git a/package.json b/package.json index d4880eb..2a15476 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@testing-library/react": "^16.3.0", "@types/fs-extra": "^11.0.4", "@types/node": "^22.18.0", - "@vitest/coverage-v8": "^4.0.0-beta.8", + "@vitest/coverage-v8": "^3.2.4", "fs-extra": "^11.3.1", "jsdom": "^26.1.0", "lefthook": "^1.12.3", @@ -33,6 +33,6 @@ "tsx": "^4.20.5", "turbo": "^2.5.6", "typescript": "^5.9.2", - "vitest": "^4.0.0-beta.8" + "vitest": "^3.2.4" } } diff --git a/packages/sqi-web/src/_util/__tests__/dom.test.ts b/packages/sqi-web/src/_util/__tests__/dom.test.ts new file mode 100644 index 0000000..b6239b5 --- /dev/null +++ b/packages/sqi-web/src/_util/__tests__/dom.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest'; +import { isDOM, getDOM, getRefDom, getReactNodeRef } from '../dom'; +import React from 'react'; + +describe('dom utilities', () => { + describe('isDOM', () => { + it('should return true for HTMLElement', () => { + const div = document.createElement('div'); + expect(isDOM(div)).toBe(true); + }); + + it('should return true for SVGElement', () => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + expect(isDOM(svg)).toBe(true); + }); + + it('should return false for non-DOM objects', () => { + expect(isDOM(null)).toBe(false); + expect(isDOM(undefined)).toBe(false); + expect(isDOM({})).toBe(false); + expect(isDOM('string')).toBe(false); + expect(isDOM(123)).toBe(false); + }); + }); + + describe('getDOM', () => { + it('should return currentElement for ref objects', () => { + const div = document.createElement('div'); + const refObject = { currentElement: div }; + expect(getDOM(refObject)).toBe(div); + }); + + it('should return the node itself if it is a DOM element', () => { + const div = document.createElement('div'); + expect(getDOM(div)).toBe(div); + }); + + it('should return null for non-DOM objects', () => { + expect(getDOM(null)).toBe(null); + expect(getDOM(undefined)).toBe(null); + expect(getDOM({})).toBe(null); + expect(getDOM('string')).toBe(null); + }); + + it('should return null for objects without currentElement', () => { + const obj = { foo: 'bar' }; + expect(getDOM(obj)).toBe(null); + }); + }); + + describe('getRefDom', () => { + it('should return undefined for falsy input', () => { + expect(getRefDom(null as any)).toBeUndefined(); + expect(getRefDom(undefined as any)).toBeUndefined(); + }); + + it('should return currentElement.currentElement for special refs', () => { + const div = document.createElement('div'); + const ref = { current: { currentElement: div } }; + expect(getRefDom(ref)).toBe(div); + }); + + it('should return current property for regular refs', () => { + const div = document.createElement('div'); + const ref = { current: div }; + expect(getRefDom(ref)).toBe(div); + }); + + it('should return undefined when current is falsy', () => { + const ref = { current: null }; + expect(getRefDom(ref)).toBeNull(); + }); + }); + + describe('getReactNodeRef', () => { + it('should return null for non-elements', () => { + expect(getReactNodeRef(null)).toBe(null); + expect(getReactNodeRef(undefined)).toBe(null); + expect(getReactNodeRef('string')).toBe(null); + expect(getReactNodeRef(123)).toBe(null); + }); + + it('should return ref based on React version', () => { + // Mock React version + const versionSpy = vi.spyOn(React, 'version', 'get'); + + // Test React 19+ behavior (ref in props) + versionSpy.mockReturnValue('19.0.0'); + const divWithRefProp = React.createElement('div', { ref: 'test-ref' }); + expect(getReactNodeRef(divWithRefProp)).toBe('test-ref'); + + // Test pre-React 19 behavior (ref as property) + versionSpy.mockReturnValue('18.2.0'); + const divWithRefProperty = React.createElement('div', {}); + expect(getReactNodeRef(divWithRefProperty)).toBeNull(); + + versionSpy.mockRestore(); + }); + + it('should return null when element has no ref', () => { + const versionSpy = vi.spyOn(React, 'version', 'get'); + versionSpy.mockReturnValue('19.0.0'); + + const divWithoutRef = React.createElement('div', {}); + expect(getReactNodeRef(divWithoutRef)).toBe(null); + + versionSpy.mockRestore(); + }); + }); +}); diff --git a/packages/sqi-web/src/_util/__tests__/ref.test.tsx b/packages/sqi-web/src/_util/__tests__/ref.test.tsx new file mode 100644 index 0000000..4ff4925 --- /dev/null +++ b/packages/sqi-web/src/_util/__tests__/ref.test.tsx @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as React from 'react'; +import { composeRef, fillRef, useComposeRef, supportRef, supportNodeRef } from '../ref'; +import { render, renderHook } from '@testing-library/react'; + +// 创建一个变量来保存模拟的React版本 +let mockReactVersion = '18.0.0'; + +// 模拟react模块以允许控制version导出 +vi.mock('react', async () => { + const actualReact = await vi.importActual('react'); + return { + ...actualReact, + get version() { + return mockReactVersion; + }, + }; +}); + +describe('ref utilities', () => { + beforeEach(() => { + mockReactVersion = '18.0.0'; + }); + + describe('fillRef', () => { + it('should call function ref with node', () => { + const ref = vi.fn(); + const node = document.createElement('div'); + fillRef(ref, node); + expect(ref).toHaveBeenCalledWith(node); + }); + + it('should set current property of object ref', () => { + const ref = { current: null }; + const node = document.createElement('div'); + fillRef(ref, node); + expect(ref.current).toBe(node); + }); + + it('should not throw error for null ref', () => { + expect(() => fillRef(null, document.createElement('div'))).not.toThrow(); + }); + }); + + describe('composeRef', () => { + it('should return undefined when no refs provided', () => { + expect(composeRef()).toBeUndefined(); + }); + + it('should return the only ref when one ref provided', () => { + const ref = vi.fn(); + expect(composeRef(ref)).toBe(ref); + }); + + it('should compose multiple function refs', () => { + const ref1 = vi.fn(); + const ref2 = vi.fn(); + const node = document.createElement('div'); + + const composedRef = composeRef(ref1, ref2); + (composedRef as React.RefCallback)(node); + + expect(ref1).toHaveBeenCalledWith(node); + expect(ref2).toHaveBeenCalledWith(node); + }); + + it('should compose multiple object refs', () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + const node = document.createElement('div'); + + const composedRef = composeRef(ref1, ref2); + (composedRef as React.RefCallback)(node); + + expect(ref1.current).toBe(node); + expect(ref2.current).toBe(node); + }); + + it('should compose mixed refs', () => { + const ref1 = vi.fn(); + const ref2 = React.createRef(); + const node = document.createElement('div'); + + const composedRef = composeRef(ref1, ref2); + (composedRef as React.RefCallback)(node); + + expect(ref1).toHaveBeenCalledWith(node); + expect(ref2.current).toBe(node); + }); + }); + + describe('useComposeRef', () => { + it('should compose refs with hook', () => { + const ref1 = vi.fn(); + const ref2 = React.createRef(); + + const TestComponent = () => { + const composedRef = useComposeRef(ref1, ref2); + return ( +
+ Test +
+ ); + }; + + const { getByTestId } = render(); + + expect(ref1).toHaveBeenCalledWith(getByTestId('test')); + expect(ref2.current).toBe(getByTestId('test')); + }); + + it('should return same ref when refs are not changed', () => { + const ref1 = vi.fn(); + const { result, rerender } = renderHook(({ ref1 }) => useComposeRef(ref1), { initialProps: { ref1 } }); + const firstRef = result.current; + + rerender({ ref1 }); + expect(result.current).toBe(firstRef); + }); + + it('should return new ref when refs changed', () => { + const ref1 = vi.fn(); + const ref2 = vi.fn(); + const { result, rerender } = renderHook(({ refs }) => useComposeRef(...refs), { initialProps: { refs: [ref1] } }); + const firstRef = result.current; + + rerender({ refs: [ref1, ref2] }); + expect(result.current).not.toBe(firstRef); + }); + }); + + describe('supportRef', () => { + it('should return false for falsy values', () => { + expect(supportRef(null)).toBe(false); + expect(supportRef(undefined)).toBe(false); + }); + + it('should return true for React 19+ elements', () => { + mockReactVersion = '19.0.0'; + + const element = React.createElement('div'); + expect(supportRef(element)).toBe(true); + }); + + it('should return false for React 19+ function components without forwardRef', () => { + mockReactVersion = '19.0.0'; + + const FunctionComponent = () =>
; + expect(supportRef(FunctionComponent)).toBe(false); + }); + + it('should return true for forwardRef components', () => { + const ForwardRefComponent = React.forwardRef(() =>
); + expect(supportRef(ForwardRefComponent)).toBe(true); + }); + + it('should return true for class components', () => { + class ClassComponent extends React.Component { + render() { + return
; + } + } + expect(supportRef(ClassComponent)).toBe(true); + }); + + it('should return false for function components without forwardRef in React 18', () => { + mockReactVersion = '18.0.0'; + + const FunctionComponent = () =>
; + expect(supportRef(FunctionComponent)).toBe(false); + }); + + it('should handle memo components', () => { + const FunctionComponent = () =>
; + const MemoComponent = React.memo(FunctionComponent); + expect(supportRef(MemoComponent)).toBe(false); + }); + }); + + describe('supportNodeRef', () => { + it('should return false for non-elements', () => { + expect(supportNodeRef(null)).toBe(false); + expect(supportNodeRef('string')).toBe(false); + expect(supportNodeRef(123)).toBe(false); + }); + + it('should return true for elements that support ref in React 19+', () => { + mockReactVersion = '19.0.0'; + + const element = React.createElement('div'); + expect(supportNodeRef(element)).toBe(true); + }); + + it('should return false for elements that do not support ref', () => { + mockReactVersion = '18.0.0'; + + const FunctionComponent = () =>
; + const element = React.createElement(FunctionComponent); + + expect(supportNodeRef(element)).toBe(false); + }); + }); +}); diff --git a/packages/sqi-web/src/_util/__tests__/reposeive.test.ts b/packages/sqi-web/src/_util/__tests__/reposeive.test.ts new file mode 100644 index 0000000..7000d6c --- /dev/null +++ b/packages/sqi-web/src/_util/__tests__/reposeive.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import responsiveObserve, { responsiveArray, responsiveMap } from '../responsiveObserve'; + +type Fn = (...args: any[]) => any; + +describe('responsiveObserve', () => { + // 保存原始的matchMedia + const originalMatchMedia = window.matchMedia; + + beforeEach(() => { + responsiveObserve.handlers = {}; + + window.matchMedia = vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + }); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + describe('responsiveArray', () => { + it('should be defined', () => { + expect(responsiveArray).toEqual(['xxl', 'xl', 'lg', 'md', 'sm', 'xs']); + }); + }); + + describe('responsiveMap', () => { + it('should be defined with correct breakpoints', () => { + expect(responsiveMap).toEqual({ + xs: '(max-width: 575px)', + sm: '(min-width: 576px)', + md: '(min-width: 768px)', + lg: '(min-width: 992px)', + xl: '(min-width: 1200px)', + xxl: '(min-width: 1600px)', + }); + }); + }); + + describe('subscribe and unsubscribe', () => { + it('should subscribe and unsubscribe correctly', () => { + const callback = vi.fn(); + + // 订阅 + const token = responsiveObserve.subscribe(callback); + expect(typeof token).toBe('number'); + expect(callback).toHaveBeenCalled(); + + // 取消订阅 + responsiveObserve.unsubscribe(token); + // 验证回调函数被调用次数 + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should call all subscribers when dispatch is called', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const token1 = responsiveObserve.subscribe(callback1); + const token2 = responsiveObserve.subscribe(callback2); + + // 模拟屏幕变化 + const screens = { xs: true, sm: false }; + responsiveObserve.dispatch(screens); + + expect(callback1).toHaveBeenCalledWith(screens); + expect(callback2).toHaveBeenCalledWith(screens); + + // 清理 + responsiveObserve.unsubscribe(token1); + responsiveObserve.unsubscribe(token2); + }); + }); + + describe('register and unregister', () => { + it('should register media queries on first subscription', () => { + const mockMql = { + matches: false, + addListener: vi.fn(), + removeListener: vi.fn(), + }; + + // 模拟matchMedia返回自定义对象 + window.matchMedia = vi.fn().mockReturnValue(mockMql); + + const callback = vi.fn(); + responsiveObserve.subscribe(callback); + + // 验证为每个断点都注册了媒体查询监听器 + expect(window.matchMedia).toHaveBeenCalledTimes(Object.keys(responsiveMap).length); + expect(mockMql.addListener).toHaveBeenCalledTimes(Object.keys(responsiveMap).length); + }); + + it('should unregister media queries when no subscribers left', () => { + const mockMqls: any[] = []; + + window.matchMedia = vi.fn().mockImplementation((query) => { + const mockMql = { + matches: false, + media: query, + addListener: vi.fn(), + removeListener: vi.fn(), + }; + mockMqls.push(mockMql); + return mockMql; + }); + + const callback = vi.fn(); + const token = responsiveObserve.subscribe(callback); + + // 确保监听器已添加 + mockMqls.forEach((mql) => { + expect(mql.addListener).toHaveBeenCalled(); + }); + + // 取消订阅,应该触发unregister + responsiveObserve.unsubscribe(token); + + // 验证监听器已被移除 + mockMqls.forEach((mql) => { + expect(mql.removeListener).toHaveBeenCalled(); + }); + }); + }); + + describe('media query handling', () => { + it('should update screens when media query matches change', () => { + const listeners: Fn[] = []; + const mqlMocks: any[] = []; + + // 创建更真实的matchMedia模拟 + window.matchMedia = vi.fn().mockImplementation((query) => { + const mqlMock = { + matches: false, + media: query, + addListener: vi.fn((listener: Fn) => { + listeners.push(listener); + }), + removeListener: vi.fn(), + }; + mqlMocks.push(mqlMock); + return mqlMock; + }); + + const callback = vi.fn(); + responsiveObserve.subscribe(callback); + + // 模拟媒体查询变化 + if (listeners[0]) { + listeners[0]({ matches: true }); + } + + // 验证回调被调用 + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('dispatch', () => { + it('should update screens and notify subscribers', () => { + const callback = vi.fn(); + responsiveObserve.subscribe(callback); + + const screens = { xs: true, sm: false, md: false, lg: false, xl: false, xxl: false }; + const result = responsiveObserve.dispatch(screens); + + expect(result).toBe(true); // 因为有订阅者 + expect(callback).toHaveBeenCalledWith(screens); + }); + + it('should return false when no subscribers', () => { + responsiveObserve.unregister(); + + const screens = { xs: true }; + const result = responsiveObserve.dispatch(screens); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/sqi-web/src/_util/__tests__/toArray.test.ts b/packages/sqi-web/src/_util/__tests__/toArray.test.ts new file mode 100644 index 0000000..8ed1a57 --- /dev/null +++ b/packages/sqi-web/src/_util/__tests__/toArray.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { toArray } from '../toArray'; +import * as React from 'react'; + +describe('toArray', () => { + it('should convert null or undefined to empty array', () => { + expect(toArray(null)).toEqual([]); + expect(toArray(undefined)).toEqual([]); + }); + + it('should convert string to array with single string element', () => { + expect(toArray('hello')).toEqual(['hello']); + }); + + it('should convert number to array with single number element', () => { + expect(toArray(42)).toEqual([42]); + }); + + it('should convert single React element to array with that element', () => { + const element = React.createElement('div', null, 'Hello'); + const result = toArray(element); + expect(result).toHaveLength(1); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[0] as any).props.children).toBe('Hello'); + }); + + it('should flatten arrays', () => { + const element1 = React.createElement('div', null, 'First'); + const element2 = React.createElement('span', null, 'Second'); + const result = toArray([element1, element2]); + expect(result).toHaveLength(2); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[1] as React.ReactElement).type).toBe('span'); + expect((result[0] as any).props.children).toBe('First'); + expect((result[1] as any).props.children).toBe('Second'); + }); + + it('should filter out null and undefined values from arrays', () => { + const element = React.createElement('div', null, 'Hello'); + const result = toArray([element, null, undefined, 'text']); + expect(result).toHaveLength(2); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[0] as any).props.children).toBe('Hello'); + expect(result[1]).toBe('text'); + }); + + it('should recursively flatten nested arrays', () => { + const element1 = React.createElement('div', null, 'First'); + const element2 = React.createElement('span', null, 'Second'); + const element3 = React.createElement('p', null, 'Third'); + const nestedArray = [element1, [element2, [element3]]]; + const result = toArray(nestedArray); + expect(result).toHaveLength(3); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[1] as React.ReactElement).type).toBe('span'); + expect((result[2] as React.ReactElement).type).toBe('p'); + expect((result[0] as any).props.children).toBe('First'); + expect((result[1] as any).props.children).toBe('Second'); + expect((result[2] as any).props.children).toBe('Third'); + }); + + it('should unwrap Fragment children', () => { + const fragment = React.createElement( + React.Fragment, + null, + React.createElement('div', null, 'Child 1'), + React.createElement('span', null, 'Child 2'), + ); + + const result = toArray(fragment); + expect(result).toHaveLength(2); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[1] as React.ReactElement).type).toBe('span'); + }); + + it('should unwrap nested Fragment children', () => { + const nestedFragment = React.createElement( + React.Fragment, + null, + React.createElement(React.Fragment, null, React.createElement('div', null, 'Deep child')), + ); + + const result = toArray(nestedFragment); + expect(result).toHaveLength(1); + expect((result[0] as React.ReactElement).type).toBe('div'); + }); + + it('should handle Fragment with no children', () => { + const emptyFragment = React.createElement(React.Fragment, null); + const result = toArray(emptyFragment); + expect(result).toEqual([]); + }); + + it('should handle Fragment with null/undefined children', () => { + const fragmentWithNulls = React.createElement(React.Fragment, null, null, undefined, 'text'); + + const result = toArray(fragmentWithNulls); + expect(result).toEqual(['text']); + }); + + it('should handle mixed content with Fragments', () => { + const element = React.createElement('div', null, 'Regular element'); + const fragment = React.createElement(React.Fragment, null, React.createElement('span', null, 'Fragment child')); + + const result = toArray([element, fragment, 'text']); + expect(result).toHaveLength(3); + expect((result[0] as React.ReactElement).type).toBe('div'); + expect((result[1] as React.ReactElement).type).toBe('span'); + expect(result[2]).toBe('text'); + }); + + it('should handle complex nested structure', () => { + const complexStructure = [ + 'text', + null, + React.createElement('div', null, 'Element'), + [ + React.createElement(React.Fragment, null, React.createElement('span', null, 'Fragment child'), null), + 'nested text', + ], + ]; + + const result = toArray(complexStructure); + expect(result).toHaveLength(4); + expect(result[0]).toBe('text'); + expect((result[1] as React.ReactElement).type).toBe('div'); + expect((result[2] as React.ReactElement).type).toBe('span'); + expect(result[3]).toBe('nested text'); + }); +}); diff --git a/packages/sqi-web/src/alert/__tests__/__snapshots__/alert.test.tsx.snap b/packages/sqi-web/src/alert/__tests__/__snapshots__/alert.test.tsx.snap new file mode 100644 index 0000000..400e4d3 --- /dev/null +++ b/packages/sqi-web/src/alert/__tests__/__snapshots__/alert.test.tsx.snap @@ -0,0 +1,310 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Alert > should display icons correctly 1`] = ` + +`; + +exports[`Alert > should display icons correctly 2`] = ` + +`; + +exports[`Alert > should render basic Alert correctly 1`] = ` + +`; + +exports[`Alert > should render different types of Alert correctly 1`] = ` + +`; + +exports[`Alert > should render different types of Alert correctly 2`] = ` + +`; + +exports[`Alert > should render different types of Alert correctly 3`] = ` + +`; + +exports[`Alert > should render different types of Alert correctly 4`] = ` + +`; + +exports[`Alert > should render title and description correctly 1`] = ` + +`; diff --git a/packages/sqi-web/src/alert/__tests__/alert.test.tsx b/packages/sqi-web/src/alert/__tests__/alert.test.tsx new file mode 100644 index 0000000..6d7ef8f --- /dev/null +++ b/packages/sqi-web/src/alert/__tests__/alert.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Alert from '../Alert'; + +describe('Alert', () => { + it('should render basic Alert correctly', () => { + const { container, getByText } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('sqi-alert'); + expect(getByText('This is an alert message')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render different types of Alert correctly', () => { + const { container: successAlert } = render(); + expect(successAlert.firstChild).toHaveClass('sqi-alert-success'); + + const { container: infoAlert } = render(); + expect(infoAlert.firstChild).toHaveClass('sqi-alert-info'); + + const { container: warningAlert } = render(); + expect(warningAlert.firstChild).toHaveClass('sqi-alert-warning'); + + const { container: errorAlert } = render(); + expect(errorAlert.firstChild).toHaveClass('sqi-alert-error'); + + expect(successAlert.firstChild).toMatchSnapshot(); + expect(infoAlert.firstChild).toMatchSnapshot(); + expect(warningAlert.firstChild).toMatchSnapshot(); + expect(errorAlert.firstChild).toMatchSnapshot(); + }); + + it('should render title and description correctly', () => { + const { container, getByText } = render(); + expect(getByText('Alert Title')).toBeInTheDocument(); + expect(getByText('Alert Description')).toBeInTheDocument(); + expect(getByText('Alert Title')).toHaveClass('sqi-alert-title'); + expect(getByText('Alert Description')).toHaveClass('sqi-alert-description'); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should display icons correctly', () => { + const { container: hasIconAlert } = render(); + + // Check default icon display + expect(hasIconAlert.firstChild).toHaveClass('sqi-alert'); + expect(hasIconAlert.firstChild?.firstChild).toHaveClass('sqi-alert-icon'); + + // Check when icon is hidden + const { container: noIconAlert } = render(); + expect(noIconAlert.firstChild?.firstChild).not.toHaveClass('sqi-alert-icon'); + + expect(hasIconAlert.firstChild).toMatchSnapshot(); + expect(noIconAlert.firstChild).toMatchSnapshot(); + }); + + it('should render custom icons correctly', () => { + const { container, getByTestId } = render( + Custom Icon} description="Custom icon alert" />, + ); + expect(getByTestId('custom-icon')).toBeInTheDocument(); + + expect(container.firstChild?.firstChild).toHaveClass('sqi-alert-icon'); + }); + + it('should render action area correctly', () => { + const action = ( + + ); + const { container } = render(); + expect(screen.getByTestId('alert-action')).toBeInTheDocument(); + expect((container.firstChild as any)?.children[2]).toHaveClass('sqi-alert-action'); + }); + + it('should render close button correctly', () => { + const { container } = render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(container.firstChild?.lastChild).toHaveClass('sqi-alert-close'); + }); + + it('should hide after clicking close button', async () => { + const { container } = render(); + + expect(container.firstChild).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button')); + + await waitFor( + () => { + expect(container.firstChild).not.toBeInTheDocument(); + }, + { timeout: 500 }, + ); + }); + + it('should trigger onClose callback when closing', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox-group.test.tsx.snap b/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox-group.test.tsx.snap new file mode 100644 index 0000000..9a1f64c --- /dev/null +++ b/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox-group.test.tsx.snap @@ -0,0 +1,103 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`CheckboxGroup Component > should render correctly with children 1`] = ` +
+ + +
+`; + +exports[`CheckboxGroup Component > should support options prop 1`] = ` +
+ + +
+`; diff --git a/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox.test.tsx.snap b/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox.test.tsx.snap new file mode 100644 index 0000000..82882af --- /dev/null +++ b/packages/sqi-web/src/checkbox/__tests__/__snapshots__/checkbox.test.tsx.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Checkbox > should render correctly 1`] = ` + +`; diff --git a/packages/sqi-web/src/checkbox/__tests__/checkbox-group.test.tsx b/packages/sqi-web/src/checkbox/__tests__/checkbox-group.test.tsx new file mode 100644 index 0000000..abca26d --- /dev/null +++ b/packages/sqi-web/src/checkbox/__tests__/checkbox-group.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import CheckboxGroup from '../CheckboxGroup'; +import Checkbox from '../Checkbox'; + +describe('CheckboxGroup Component', () => { + it('should render correctly with children', () => { + const { container, getByText } = render( + + Option 1 + Option 2 + , + ); + + expect(container.firstChild).toBeInTheDocument(); + expect(getByText('Option 1')).toBeInTheDocument(); + expect(getByText('Option 2')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support defaultValue', () => { + const { container } = render( + + Option 1 + Option 2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toBeChecked(); + expect(inputs[1]).not.toBeChecked(); + }); + + it('should support value (controlled mode)', () => { + const { container } = render( + + Option 1 + Option 2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toBeChecked(); + expect(inputs[1]).not.toBeChecked(); + }); + + it('should trigger onChange when checkbox is clicked', () => { + const onChange = vi.fn(); + const { getByText } = render( + + Option 1 + Option 2 + , + ); + + // Click first checkbox + fireEvent.click(getByText('Option 1')); + expect(onChange).toHaveBeenCalledWith(['1']); + + // Click second checkbox + fireEvent.click(getByText('Option 2')); + expect(onChange).toHaveBeenCalledWith(['1', '2']); + }); + + it('should support options prop', () => { + const { container, getByText } = render( + , + ); + + expect(getByText('Option 1')).toBeInTheDocument(); + expect(getByText('Option 2')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support string and number array for options prop', () => { + const { container, getByText } = render(); + + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('123')).toBeInTheDocument(); + + const inputs = container.querySelectorAll('input'); + expect(inputs).toHaveLength(3); + }); + + it('should support disabled state', () => { + const { container } = render( + + Option 1 + Option 2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toBeDisabled(); + expect(inputs[1]).toBeDisabled(); + }); + + it('should support custom className and style', () => { + const { container } = render( + + Option 1 + , + ); + + expect(container.firstChild).toHaveClass('custom-group'); + // expect(container.firstChild).toHaveStyle('background-color: red'); + expect((container.firstChild as HTMLInputElement)?.getAttribute('style')).toContain('background-color: red'); + }); + + it('should work with renderOption', () => { + const { getByTestId } = render( + Custom {option.label}} + />, + ); + + expect(getByTestId('custom-1')).toBeInTheDocument(); + expect(getByTestId('custom-2')).toBeInTheDocument(); + }); + + it('should handle checkbox toggle correctly', () => { + const onChange = vi.fn(); + const { getByText } = render( + + Option 1 + Option 2 + , + ); + + // Uncheck first checkbox + fireEvent.click(getByText('Option 1')); + expect(onChange).toHaveBeenCalledWith([]); + + // Check second checkbox + fireEvent.click(getByText('Option 2')); + expect(onChange).toHaveBeenCalledWith(['1', '2']); + }); + + it('should support name attribute', () => { + const { container } = render( + + Option 1 + Option 2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toHaveAttribute('name', 'test-group'); + expect(inputs[1]).toHaveAttribute('name', 'test-group'); + }); +}); diff --git a/packages/sqi-web/src/checkbox/__tests__/checkbox.test.tsx b/packages/sqi-web/src/checkbox/__tests__/checkbox.test.tsx new file mode 100644 index 0000000..89aa640 --- /dev/null +++ b/packages/sqi-web/src/checkbox/__tests__/checkbox.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import Checkbox from '../Checkbox'; +import CheckboxGroup from '../CheckboxGroup'; + +describe('Checkbox', () => { + it('should render correctly', () => { + const { container, getByText } = render(Checkbox); + expect(container.firstChild).toBeInTheDocument(); + expect(getByText('Checkbox')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support defaultChecked', () => { + const { container } = render(Checkbox); + expect(container.querySelector('input')).toBeChecked(); + }); + + it('should support checked', () => { + const { container } = render(Checkbox); + expect(container.querySelector('input')).toBeChecked(); + }); + + it('should trigger onChange when clicked', () => { + const onChange = vi.fn(); + const { getByRole } = render(Checkbox); + + const input = getByRole('checkbox'); + fireEvent.click(input); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + checked: true, + }), + }), + ); + }); + + it('should support disabled state', () => { + const onChange = vi.fn(); + const { getByRole } = render( + + Checkbox + , + ); + + const input = getByRole('checkbox'); + expect(input).toBeDisabled(); + + fireEvent.click(input); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should support indeterminate state', () => { + const { container } = render(Checkbox); + expect(container.firstChild?.firstChild).toHaveClass('sqi-checkbox-indeterminate'); + }); + + it('should work with CheckboxGroup', () => { + const onChange = vi.fn(); + const { container, getByText } = render( + + Option 1 + Option 2 + , + ); + + expect(container.querySelectorAll('input')[0]).toBeChecked(); + expect(container.querySelectorAll('input')[1]).not.toBeChecked(); + + // Click second checkbox + fireEvent.click(getByText('Option 2')); + expect(onChange).toHaveBeenCalledWith(['1', '2']); + }); + + it('should respect Checkbox disabled over CheckboxGroup disabled', () => { + const { container } = render( + + + Option 1 + + Option 2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).not.toBeDisabled(); // Explicitly enabled + expect(inputs[1]).toBeDisabled(); // Inherited from group + }); + + it('should support custom className and style', () => { + const { container } = render( + + Checkbox + , + ); + + expect(container.firstChild).toHaveClass('custom-class'); + // expect(container.firstChild).toHaveStyle('color: red'); + expect((container.firstChild?.firstChild as HTMLInputElement)?.getAttribute('style')).toContain('color: red'); + }); +}); diff --git a/packages/sqi-web/src/checkbox/type.ts b/packages/sqi-web/src/checkbox/type.ts index c6cac4c..47000b7 100644 --- a/packages/sqi-web/src/checkbox/type.ts +++ b/packages/sqi-web/src/checkbox/type.ts @@ -32,7 +32,7 @@ export interface CheckboxGroupProps { /** * @description 配置形式设置子元素 */ - options?: CheckboxOptions[] | string[] | number[]; + options?: CheckboxOptions[] | string[] | number[] | (string | number)[]; /** * @description 自定义渲染内容, 仅使用 options 时生效 */ diff --git a/packages/sqi-web/src/config-provider/ConfigProvider.tsx b/packages/sqi-web/src/config-provider/ConfigProvider.tsx index bba1f29..72e0cd3 100644 --- a/packages/sqi-web/src/config-provider/ConfigProvider.tsx +++ b/packages/sqi-web/src/config-provider/ConfigProvider.tsx @@ -9,7 +9,7 @@ import type { ConfigProviderProps } from './type'; export default function ConfigProvider(baseProps: ConfigProviderProps) { const props = useMergeProps(baseProps, defaultConfigProps); const { iconPrefix, children } = props; - const providerValue = omit(props, ['children', 'iconPrefix']); + const providerValue = omit(props, ['children']); const IconProviderPlaceholder = iconPrefix ? IconContext.Provider : Fragment; diff --git a/packages/sqi-web/src/config-provider/__tests__/config-provider.test.tsx b/packages/sqi-web/src/config-provider/__tests__/config-provider.test.tsx new file mode 100644 index 0000000..1feb7e0 --- /dev/null +++ b/packages/sqi-web/src/config-provider/__tests__/config-provider.test.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import ConfigProvider from '../ConfigProvider'; +import { ConfigContext } from '../context'; + +// 创建一个测试组件来验证 ConfigProvider 的值 +const TestComponent = () => { + const context = React.useContext(ConfigContext); + + return ( +
+ {context.prefixCls} + {context.iconPrefix} + {context.size} +
+ ); +}; + +describe('ConfigProvider', () => { + it('should render children correctly', () => { + const { container } = render( + +
Test Child
+
, + ); + + expect(container.firstChild).toBeInTheDocument(); + expect(container.textContent).toBe('Test Child'); + }); + + it('should provide default values', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('prefixCls').textContent).toBe('sqi'); + expect(getByTestId('iconPrefix').textContent).toBe('sqi'); + }); + + it('should allow customization of prefixCls', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('prefixCls').textContent).toBe('my-prefix'); + expect(getByTestId('iconPrefix').textContent).toBe('sqi'); // iconPrefix 应该保持默认值 + }); + + it('should allow customization of iconPrefix', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('iconPrefix').textContent).toBe('my-icon'); + expect(getByTestId('prefixCls').textContent).toBe('sqi'); // prefixCls 应该保持默认值 + }); + + it('should allow customization of both prefixCls and iconPrefix', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('prefixCls').textContent).toBe('my-prefix'); + expect(getByTestId('iconPrefix').textContent).toBe('my-icon'); + }); + + it('should allow customization of size', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('size').textContent).toBe('lg'); + }); + + it('should support componentConfig', () => { + const componentConfig = { + Alert: { type: 'success' as const }, + Button: { type: 'primary' as const }, + }; + + const ComponentWithConfig = () => { + const context = React.useContext(ConfigContext); + return ( +
+ {context.componentConfig?.Alert?.type} + {context.componentConfig?.Button?.type} +
+ ); + }; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('alertType').textContent).toBe('success'); + expect(getByTestId('buttonType').textContent).toBe('primary'); + }); + + it('should work with nested ConfigProviders', () => { + const { getByTestId } = render( + + + + + , + ); + + expect(getByTestId('prefixCls').textContent).toBe('inner'); + }); +}); diff --git a/packages/sqi-web/src/config-provider/type.ts b/packages/sqi-web/src/config-provider/type.ts index 65540da..a41c4f0 100644 --- a/packages/sqi-web/src/config-provider/type.ts +++ b/packages/sqi-web/src/config-provider/type.ts @@ -31,7 +31,7 @@ export interface ConfigProviderProps { prefixCls?: string; /** * @description 组件图标前缀 - * @default 'sqi-icon' + * @default 'sqi' */ iconPrefix?: string; children?: ReactNode; diff --git a/packages/sqi-web/src/divider/Divider.tsx b/packages/sqi-web/src/divider/Divider.tsx index c3040ac..ec28da5 100644 --- a/packages/sqi-web/src/divider/Divider.tsx +++ b/packages/sqi-web/src/divider/Divider.tsx @@ -11,19 +11,25 @@ const defaultProps: DividerProps = { const Divider = forwardRef((baseProps: DividerProps, ref) => { const { prefixCls, componentConfig } = useContext(ConfigContext); - const props = useMergeProps(baseProps, defaultProps, componentConfig?.Divider); - const { direction, align, dashed, className, children, text, style } = props; + const { direction, align, dashed, className, children, text, style } = useMergeProps( + baseProps, + defaultProps, + componentConfig?.Divider, + ); const mergeChildren = children || text; const hasText = direction !== 'vertical' && !!mergeChildren; - const classes = clsx(`${prefixCls}-divider`, { - [`${prefixCls}-divider-${direction}`]: direction, - [`${prefixCls}-divider-with-text`]: hasText, - [`${prefixCls}-divider-with-text-${align}`]: hasText, - [`${prefixCls}-divider-dashed`]: !!dashed, + const classes = clsx( + `${prefixCls}-divider`, + { + [`${prefixCls}-divider-${direction}`]: direction, + [`${prefixCls}-divider-with-text`]: hasText, + [`${prefixCls}-divider-with-text-${align}`]: hasText, + [`${prefixCls}-divider-dashed`]: !!dashed, + }, className, - }); + ); return (
diff --git a/packages/sqi-web/src/divider/__tests__/__snapshots__/divider.test.tsx.snap b/packages/sqi-web/src/divider/__tests__/__snapshots__/divider.test.tsx.snap new file mode 100644 index 0000000..ea313a4 --- /dev/null +++ b/packages/sqi-web/src/divider/__tests__/__snapshots__/divider.test.tsx.snap @@ -0,0 +1,98 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Divider > should combine className correctly 1`] = ` +
+`; + +exports[`Divider > should not render text for vertical divider 1`] = ` +
+`; + +exports[`Divider > should prioritize children over text prop 1`] = ` +
+ + Children Text + +
+`; + +exports[`Divider > should render correctly with default props 1`] = ` +
+`; + +exports[`Divider > should render dashed divider 1`] = ` +
+`; + +exports[`Divider > should render vertical divider 1`] = ` +
+`; + +exports[`Divider > should render with text content 1`] = ` +
+ + Text Content + +
+`; + +exports[`Divider > should render with text prop 1`] = ` +
+ + Text Prop + +
+`; + +exports[`Divider > should support custom className and style 1`] = ` +
+`; + +exports[`Divider > should support text alignment 1`] = ` +
+ + Right Text + +
+`; + +exports[`Divider > should support text alignment 2`] = ` +
+ + Center Text + +
+`; diff --git a/packages/sqi-web/src/divider/__tests__/divider.test.tsx b/packages/sqi-web/src/divider/__tests__/divider.test.tsx new file mode 100644 index 0000000..785020f --- /dev/null +++ b/packages/sqi-web/src/divider/__tests__/divider.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import Divider from '../Divider'; + +describe('Divider', () => { + it('should render correctly with default props', () => { + const { container } = render(); + + expect(container.firstChild).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('sqi-divider'); + expect(container.firstChild).toHaveClass('sqi-divider-horizontal'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render vertical divider', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('sqi-divider-vertical'); + expect(container.firstChild).not.toHaveClass('sqi-divider-horizontal'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render dashed divider', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('sqi-divider-dashed'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render with text content', () => { + const { container, getByText } = render(Text Content); + + expect(container.firstChild).toHaveClass('sqi-divider-with-text'); + expect(container.firstChild).toHaveClass('sqi-divider-with-text-center'); + expect(getByText('Text Content')).toBeInTheDocument(); + expect(getByText('Text Content')).toHaveClass('sqi-divider-inner-text'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render with text prop', () => { + const { container, getByText } = render(); + + expect(container.firstChild).toHaveClass('sqi-divider-with-text'); + expect(getByText('Text Prop')).toBeInTheDocument(); + expect(getByText('Text Prop')).toHaveClass('sqi-divider-inner-text'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should prioritize children over text prop', () => { + const { container, getByText } = render(Children Text); + expect(getByText('Children Text')).toBeInTheDocument(); + expect(container.querySelector('.sqi-divider-inner-text')?.textContent).toBe('Children Text'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support text alignment', () => { + // Left alignment + const { container: leftContainer, rerender } = render(Left Text); + expect(leftContainer.firstChild).toHaveClass('sqi-divider-with-text-left'); + + // Right alignment + rerender(Right Text); + expect(leftContainer.firstChild).toHaveClass('sqi-divider-with-text-right'); + + // Center alignment (default) + const { container: rightContainer } = render(Center Text); + expect(rightContainer.firstChild).toHaveClass('sqi-divider-with-text-center'); + + expect(leftContainer.firstChild).toMatchSnapshot(); + expect(rightContainer.firstChild).toMatchSnapshot(); + }); + + it('should not render text for vertical divider', () => { + const { container, queryByText } = render(Vertical Text); + + expect(container.firstChild).not.toHaveClass('sqi-divider-with-text'); + expect(queryByText('Vertical Text')).not.toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support custom className and style', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + expect((container.firstChild as HTMLDivElement).getAttribute('style')).toContain('red'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should combine className correctly', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('sqi-divider'); + expect(container.firstChild).toHaveClass('sqi-divider-horizontal'); + expect(container.firstChild).toHaveClass('custom-class'); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/grid/__tests__/__snapshots__/col.test.tsx.snap b/packages/sqi-web/src/grid/__tests__/__snapshots__/col.test.tsx.snap new file mode 100644 index 0000000..9278261 --- /dev/null +++ b/packages/sqi-web/src/grid/__tests__/__snapshots__/col.test.tsx.snap @@ -0,0 +1,101 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Col > should render correctly with default props 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support custom className and style 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support flex prop with basis string 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support flex prop with flex string 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support flex prop with number 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support gutter from Row context 1`] = ` +
+
+
+ Col Content +
+
+
+`; + +exports[`Col > should support offset prop 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support order prop 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support responsive object props 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support responsive props 1`] = ` +
+ Col Content +
+`; + +exports[`Col > should support span prop 1`] = ` +
+ Col Content +
+`; diff --git a/packages/sqi-web/src/grid/__tests__/__snapshots__/row.test.tsx.snap b/packages/sqi-web/src/grid/__tests__/__snapshots__/row.test.tsx.snap new file mode 100644 index 0000000..db88b20 --- /dev/null +++ b/packages/sqi-web/src/grid/__tests__/__snapshots__/row.test.tsx.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Row > should render correctly with default props 1`] = ` +
+ Row Content +
+`; + +exports[`Row > should support custom className and style 1`] = ` +
+ Row Content +
+`; + +exports[`Row > should support gutter prop 1`] = ` +
+ Row Content +
+`; + +exports[`Row > should support vertical gutter 1`] = ` +
+ Row Content +
+`; + +exports[`Row > should support wrap prop 1`] = ` +
+ Row Content +
+`; diff --git a/packages/sqi-web/src/grid/__tests__/col.test.tsx b/packages/sqi-web/src/grid/__tests__/col.test.tsx new file mode 100644 index 0000000..85bfcd6 --- /dev/null +++ b/packages/sqi-web/src/grid/__tests__/col.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import Row from '../Row'; +import Col from '../Col'; +import { mockMatchMedia } from './util'; + +describe('Col', () => { + beforeEach(() => { + mockMatchMedia(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render correctly with default props', () => { + const { container } = render(Col Content); + expect(container.firstChild).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('sqi-col'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support span prop', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveClass('sqi-col-12'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support offset prop', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveClass('sqi-col-offset-6'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support order prop', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveClass('sqi-col-order-2'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support flex prop with number', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveStyle('flex: 1 1 auto'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support flex prop with basis string', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveStyle('flex: 0 0 200px'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support flex prop with flex string', () => { + const { container } = render(Col Content); + expect(container.firstChild).toHaveStyle('flex: 1 1 auto'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support responsive props', () => { + const { container } = render( + + Col Content + , + ); + expect(container.firstChild).toHaveClass('sqi-col-xs-12'); + expect(container.firstChild).toHaveClass('sqi-col-sm-8'); + expect(container.firstChild).toHaveClass('sqi-col-md-6'); + expect(container.firstChild).toHaveClass('sqi-col-lg-4'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support responsive object props', () => { + const { container } = render( + + Col Content + , + ); + expect(container.firstChild).toHaveClass('sqi-col-xs-12'); + expect(container.firstChild).toHaveClass('sqi-col-xs-offset-2'); + expect(container.firstChild).toHaveClass('sqi-col-sm-8'); + expect(container.firstChild).toHaveClass('sqi-col-md-6'); + expect(container.firstChild).toHaveClass('sqi-col-md-order-1'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support gutter from Row context', () => { + const { container } = render( + + Col Content + , + ); + + const col = container.querySelector('.sqi-col'); + expect(col).toHaveStyle('padding-left: 8px'); + expect(col).toHaveStyle('padding-right: 8px'); + expect(col).toHaveStyle('padding-top: 12px'); + expect(col).toHaveStyle('padding-bottom: 12px'); + expect(container).toMatchSnapshot(); + }); + + it('should support custom className and style', () => { + const { container } = render( + + Col Content + , + ); + expect(container.firstChild).toHaveClass('custom-col'); + expect((container.firstChild as HTMLDivElement).getAttribute('style')).toContain('blue'); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/grid/__tests__/row.test.tsx b/packages/sqi-web/src/grid/__tests__/row.test.tsx new file mode 100644 index 0000000..1ee5875 --- /dev/null +++ b/packages/sqi-web/src/grid/__tests__/row.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import Row from '../Row'; +import { mockMatchMedia } from './util'; + +describe('Row', () => { + beforeEach(() => { + mockMatchMedia(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render correctly with default props', () => { + const { container } = render(Row Content); + expect(container.firstChild).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('sqi-row'); + expect(container.firstChild).not.toHaveClass('sqi-row-nowrap'); + expect(container.firstChild).toHaveClass('sqi-row-align-start'); + expect(container.firstChild).toHaveClass('sqi-row-justify-start'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support align prop', () => { + const { container, rerender } = render(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-align-center'); + + rerender(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-align-end'); + + rerender(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-align-stretch'); + }); + + it('should support justify prop', () => { + const { container, rerender } = render(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-justify-center'); + + rerender(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-justify-end'); + + rerender(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-justify-space-around'); + + rerender(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-justify-space-between'); + }); + + it('should support wrap prop', () => { + const { container } = render(Row Content); + expect(container.firstChild).toHaveClass('sqi-row-nowrap'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support gutter prop', () => { + const { container } = render(Row Content); + expect(container.firstChild).toHaveStyle('margin-left: -8px'); + expect(container.firstChild).toHaveStyle('margin-right: -8px'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support vertical gutter', () => { + const { container } = render(Row Content); + expect(container.firstChild).toHaveStyle('margin-left: -8px'); + expect(container.firstChild).toHaveStyle('margin-right: -8px'); + expect(container.firstChild).toHaveStyle('margin-top: -12px'); + expect(container.firstChild).toHaveStyle('margin-bottom: -12px'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support custom className and style', () => { + const { container } = render( + + Row Content + , + ); + expect(container.firstChild).toHaveClass('custom-row'); + expect((container.firstChild as HTMLDivElement).getAttribute('style')).toContain('red'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should combine className correctly', () => { + const { container } = render(Row Content); + expect(container.firstChild).toHaveClass('sqi-row'); + expect(container.firstChild).toHaveClass('custom-row'); + }); +}); diff --git a/packages/sqi-web/src/grid/__tests__/util.ts b/packages/sqi-web/src/grid/__tests__/util.ts new file mode 100644 index 0000000..1f0b4fe --- /dev/null +++ b/packages/sqi-web/src/grid/__tests__/util.ts @@ -0,0 +1,17 @@ +import { vi } from 'vitest'; + +export const mockMatchMedia = () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}; diff --git a/packages/sqi-web/src/input/Input.tsx b/packages/sqi-web/src/input/Input.tsx index 26744fa..055a291 100644 --- a/packages/sqi-web/src/input/Input.tsx +++ b/packages/sqi-web/src/input/Input.tsx @@ -247,7 +247,7 @@ const Input = forwardRef((baseProps, ref) => { // input core element const inputElement = ( - ((baseProps, ref) => { {clearElement} {suffixElement} {limitLengthElement} - +
); const addBeforeElement = addonBefore && {addonBefore}; diff --git a/packages/sqi-web/src/input/__tests__/__snapshots__/input.test.tsx.snap b/packages/sqi-web/src/input/__tests__/__snapshots__/input.test.tsx.snap new file mode 100644 index 0000000..bf40ec4 --- /dev/null +++ b/packages/sqi-web/src/input/__tests__/__snapshots__/input.test.tsx.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Input > should render correctly with default props 1`] = ` +
+ +
+`; + +exports[`Input > should support addonBefore and addonAfter 1`] = ` +
+
+ + http:// + +
+ +
+ + .com + +
+
+`; + +exports[`Input > should support maxLength object config 1`] = ` +
+
+ + + 6 + / + 5 + +
+
+`; + +exports[`Input > should support password type with visibility toggle 1`] = ` +
+
+ + + + + + +
+
+`; + +exports[`Input > should support prefix and suffix 1`] = ` +
+ + $ + + + + .00 + +
+`; + +exports[`Input > should support tips 1`] = ` +
+
+
+ +
+
+ This is a tip +
+
+
+`; diff --git a/packages/sqi-web/src/input/__tests__/input.test.tsx b/packages/sqi-web/src/input/__tests__/input.test.tsx new file mode 100644 index 0000000..77f07ea --- /dev/null +++ b/packages/sqi-web/src/input/__tests__/input.test.tsx @@ -0,0 +1,224 @@ +import * as React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import Input from '../Input'; + +describe('Input', () => { + it('should render correctly with default props', () => { + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(container.firstChild).toHaveClass('sqi-input'); + expect(container.firstChild).toHaveClass('sqi-input-size-md'); + expect(container.firstChild).toHaveClass('sqi-input-variant-outline'); + expect(container.firstChild).toHaveClass('sqi-input-align-left'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support different sizes', () => { + const { container, rerender } = render(); + expect(container.firstChild).toHaveClass('sqi-input-size-sm'); + + rerender(); + expect(container.firstChild).toHaveClass('sqi-input-size-lg'); + }); + + it('should support different variants', () => { + const { container, rerender } = render(); + expect(container.firstChild).toHaveClass('sqi-input-variant-borderless'); + + rerender(); + expect(container.firstChild).toHaveClass('sqi-input-variant-underline'); + }); + + it('should support different alignments', () => { + const { container, rerender } = render(); + expect(container.firstChild).toHaveClass('sqi-input-align-center'); + + rerender(); + expect(container.firstChild).toHaveClass('sqi-input-align-right'); + }); + + it('should support status', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('sqi-input-status-error'); + }); + + it('should support placeholder', () => { + const { getByPlaceholderText } = render(); + expect(getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('should support defaultValue', () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue('Default value')).toBeInTheDocument(); + }); + + it('should support value (controlled mode)', () => { + const { getByDisplayValue } = render(); + expect(getByDisplayValue('Controlled value')).toBeInTheDocument(); + }); + + it('should trigger onChange when input value changes', () => { + const onChange = vi.fn(); + const { container } = render(); + + const input = container.querySelector('input'); + fireEvent.change(input!, { target: { value: 'new value' } }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('new value', expect.any(Object)); + }); + + it('should support disabled state', () => { + const { container } = render(); + + const input = container.querySelector('input'); + expect(input).toBeDisabled(); + }); + + it('should support readOnly state', () => { + const { container } = render(); + + const input = container.querySelector('input'); + expect(input).toHaveAttribute('readonly'); + }); + + it('should support allowClear', () => { + const onChange = vi.fn(); + const { container } = render(); + + const clearButton = container.querySelector('button'); + expect(clearButton).toBeInTheDocument(); + + fireEvent.click(clearButton!); + expect(onChange).toHaveBeenCalledWith('', expect.any(Object)); + + const input = container.querySelector('input'); + expect(input).toHaveValue(''); + }); + + it('should not show clear button when disabled', () => { + const { container } = render(); + + const clearButton = container.querySelector('button'); + expect(clearButton).not.toBeInTheDocument(); + }); + + it('should not show clear button when no value', () => { + const { container } = render(); + + const clearButton = container.querySelector('button'); + expect(clearButton).not.toBeInTheDocument(); + }); + + it('should support prefix and suffix', () => { + const { container } = render(); + + expect(container.querySelector('.sqi-input-prefix')).toBeInTheDocument(); + expect(container.querySelector('.sqi-input-suffix')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should support addonBefore and addonAfter', () => { + const { container } = render(); + + expect(container.querySelector('.sqi-input-group-addon')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should support password type with visibility toggle', () => { + const { container } = render(); + + const input = container.querySelector('input'); + expect(input).toHaveAttribute('type', 'password'); + + // 密码可见性切换是一个具有 role="button" 的 span 元素,而不是 button 元素 + const toggleButton = container.querySelector('[role="button"]'); + expect(toggleButton).toBeInTheDocument(); + + fireEvent.click(toggleButton!); + expect(input).toHaveAttribute('type', 'text'); + + fireEvent.click(toggleButton!); + expect(input).toHaveAttribute('type', 'password'); + + expect(container).toMatchSnapshot(); + }); + + it('should support custom visibilityToggle', () => { + const onVisibleChange = vi.fn(); + const { rerender, container } = render( + , + ); + + const input = container.querySelector('input'); + expect(input).toHaveAttribute('type', 'password'); + + const toggleButton = container.querySelector('[role="button"]'); + fireEvent.click(toggleButton!); + expect(onVisibleChange).toHaveBeenCalledWith(true); + + rerender(); + + expect(input).toHaveAttribute('type', 'text'); + }); + + it('should support maxLength', () => { + const { container } = render(); + + const input = container.querySelector('input'); + expect(input).toHaveValue('12345'); + }); + + it('should support maxLength object config', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.sqi-input-limit-length-text')).toBeInTheDocument(); + // 检查包含错误类的元素,应该是整个 input wrapper + expect(container.firstChild).toHaveClass('sqi-input-limit-length-error'); + expect(container).toMatchSnapshot(); + }); + + it('should support tips', () => { + const { container } = render(); + + expect(container.querySelector('.sqi-input-tips')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should support tips with status', () => { + const { container } = render(); + + expect(container.querySelector('.sqi-input-tips')).toBeInTheDocument(); + expect(container.querySelector('.sqi-input-tips')).toHaveClass('sqi-input-tips-status-error'); + }); + + it('should handle focus and blur events', () => { + const onFocus = vi.fn(); + const onBlur = vi.fn(); + const { container } = render(); + + const input = container.querySelector('input'); + fireEvent.focus(input!); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(container.querySelector('[role="group"]')).toHaveClass('sqi-input-focus'); + + fireEvent.blur(input!); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(container.querySelector('[role="group"]')).not.toHaveClass('sqi-input-focus'); + }); + + it('should support ref methods', () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeDefined(); + expect(ref.current.focus).toBeInstanceOf(Function); + expect(ref.current.blur).toBeInstanceOf(Function); + expect(ref.current.select).toBeInstanceOf(Function); + expect(ref.current.currentElement).toBeInstanceOf(HTMLDivElement); + expect(ref.current.inputElement).toBeInstanceOf(HTMLInputElement); + }); +}); diff --git a/packages/sqi-web/src/popup/Popup.tsx b/packages/sqi-web/src/popup/Popup.tsx index d7042a8..bfb7b04 100644 --- a/packages/sqi-web/src/popup/Popup.tsx +++ b/packages/sqi-web/src/popup/Popup.tsx @@ -1,10 +1,10 @@ 'use client'; import React, { forwardRef, isValidElement, useContext, useImperativeHandle, useRef } from 'react'; -import type { PopupProps } from './type'; +import clsx from 'clsx'; import { useMergeProps } from '@sqi-ui/hooks'; -import { ConfigContext } from '../config-provider/context'; import Trigger from '../trigger'; -import clsx from 'clsx'; +import { ConfigContext } from '../config-provider/context'; +import type { PopupProps } from './type'; const defaultProps: PopupProps = { trigger: 'hover', diff --git a/packages/sqi-web/src/popup/__tests__/popup.test.tsx b/packages/sqi-web/src/popup/__tests__/popup.test.tsx new file mode 100644 index 0000000..67ce6f3 --- /dev/null +++ b/packages/sqi-web/src/popup/__tests__/popup.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import Popup from '../Popup'; + +describe('Popup', () => { + test('should render correctly with hover', async () => { + const { container, queryByTestId } = render( + Popup content
}> + + , + ); + + // mouse enter before, popup is unmount + await waitFor(() => { + const popupEl = document.querySelector('.sqi-popup'); + expect(popupEl).toBeNull(); + const popupContentEl = queryByTestId('popup-test-id'); + expect(popupContentEl).not.toBeInTheDocument(); + }); + + act(() => { + fireEvent.mouseEnter(container.firstChild!); + }); + + await waitFor(() => { + const popupContentEl = queryByTestId('popup-test-id'); + expect(popupContentEl).not.toBeNull(); + expect(popupContentEl).toBeInTheDocument(); + }); + + act(() => { + fireEvent.mouseLeave(container.firstChild!); + }); + + await waitFor(() => { + const popupEl2 = queryByTestId('popup-test-id'); + expect(popupEl2).toBeNull(); + }); + }); + + test('should not render with empty children', async () => { + const { container } = render(Popup content
}>); + + expect(container.firstChild).toBeNull(); + }); + + test('should support string children', async () => { + const { container, queryByText } = render( + + + , + ); + + act(() => { + fireEvent.mouseEnter(container.firstChild!); + }); + + await waitFor(() => { + const popupEl2 = queryByText('Popup string content'); + expect(popupEl2).not.toBeNull(); + expect(popupEl2).toBeInTheDocument(); + }); + }); + + test('should not render arrow with showArrow is false', async () => { + const TestComponent = () => { + const [showArrow, setShowArrow] = React.useState(true); + + return ( + <> + + + + + + ); + }; + + const { queryByTestId } = render(); + + act(() => { + fireEvent.mouseEnter(queryByTestId('hover-trigger')!); + }); + + await waitFor(() => { + const arrowEl = document.querySelector('.sqi-popup-arrow'); + expect(arrowEl).not.toBeNull(); + expect(arrowEl).toBeInTheDocument(); + }); + + act(() => { + fireEvent.mouseLeave(queryByTestId('hover-trigger')!); + }); + + act(() => { + fireEvent.click(queryByTestId('control-arrow')!); + }); + + await waitFor(() => { + const arrowEl = document.querySelector('.sqi-popup-arrow'); + expect(arrowEl).toBeNull(); + expect(arrowEl).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/sqi-web/src/radio/__tests__/__snapshots__/radio-group.test.tsx.snap b/packages/sqi-web/src/radio/__tests__/__snapshots__/radio-group.test.tsx.snap new file mode 100644 index 0000000..2971a06 --- /dev/null +++ b/packages/sqi-web/src/radio/__tests__/__snapshots__/radio-group.test.tsx.snap @@ -0,0 +1,509 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RadioGroup > should render correctly 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support button appearance with options configuration 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support buttonVariant filled 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support defaultValue 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support disabled 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support options prop 1`] = ` +
+ + + +
+`; + +exports[`RadioGroup > should support options with string values 1`] = ` +
+ + + +
+`; + +exports[`RadioGroup > should support size attribute 1`] = ` +
+ + +
+`; + +exports[`RadioGroup > should support value 1`] = ` +
+ + +
+`; diff --git a/packages/sqi-web/src/radio/__tests__/__snapshots__/radio.test.tsx.snap b/packages/sqi-web/src/radio/__tests__/__snapshots__/radio.test.tsx.snap new file mode 100644 index 0000000..ab70ade --- /dev/null +++ b/packages/sqi-web/src/radio/__tests__/__snapshots__/radio.test.tsx.snap @@ -0,0 +1,248 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Radio > should render correctly 1`] = ` + +`; + +exports[`Radio > should support button style when _IS_BUTTON_ is true 1`] = ` + +`; + +exports[`Radio > should support checked 1`] = ` + +`; + +exports[`Radio > should support defaultChecked 1`] = ` + +`; + +exports[`Radio > should support disabled 1`] = ` + +`; + +exports[`Radio > should support function as children 1`] = ` +
+ +
+`; + +exports[`Radio > should trigger onChange in group 1`] = ` +
+ + +
+`; + +exports[`Radio > should work in group 1`] = ` +
+ + +
+`; diff --git a/packages/sqi-web/src/radio/__tests__/radio-group.test.tsx b/packages/sqi-web/src/radio/__tests__/radio-group.test.tsx new file mode 100644 index 0000000..94298d9 --- /dev/null +++ b/packages/sqi-web/src/radio/__tests__/radio-group.test.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import RadioGroup from '../RadioGroup'; +import Radio from '../Radio'; + +describe('RadioGroup', () => { + test('should render correctly', () => { + const { container } = render( + + Radio1 + Radio2 + , + ); + expect(container).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support defaultValue', () => { + const { container, getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support value', () => { + const { container, getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toBeChecked(); + expect(radios[1]).not.toBeChecked(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support disabled', () => { + const { container, getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toBeDisabled(); + expect(radios[1]).toBeDisabled(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should trigger onChange when radio is clicked', () => { + const handleChange = vi.fn(); + const { getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + fireEvent.click(radios[1]); + + expect(handleChange).toBeCalled(); + expect(handleChange.mock.calls[0][0].target.value).toBe('2'); + }); + + test('should support name attribute', () => { + const { container } = render( + + Radio1 + Radio2 + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs[0]).toHaveAttribute('name', 'test-group'); + expect(inputs[1]).toHaveAttribute('name', 'test-group'); + }); + + test('should support size attribute', () => { + const { container } = render( + + Radio1 + Radio2 + , + ); + + expect(container.querySelector('.sqi-radio-wrapper-size-lg')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support button appearance with options configuration', () => { + const { container } = render(); + + expect(container.querySelector('.sqi-radio-button-wrapper')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support options prop', () => { + const options = [ + { label: 'Radio1', value: '1' }, + { label: 'Radio2', value: '2' }, + { label: 'Radio3', value: '3', disabled: true }, + ]; + + const { container, getByText, getAllByRole } = render(); + + expect(getByText('Radio1')).toBeInTheDocument(); + expect(getByText('Radio2')).toBeInTheDocument(); + expect(getByText('Radio3')).toBeInTheDocument(); + + const radios = getAllByRole('radio'); + expect(radios[2]).toBeDisabled(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support options with string values', () => { + const options = ['Apple', 'Banana', 'Orange']; + + const { container, getByText } = render(); + + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('Orange')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support renderOption function', () => { + const options = [ + { label: 'Radio1', value: '1' }, + { label: 'Radio2', value: '2' }, + ]; + + const renderOption = (item: any) => {item.label} - Custom; + + const { getByTestId } = render(); + + expect(getByTestId('custom-1')).toBeInTheDocument(); + expect(getByTestId('custom-2')).toBeInTheDocument(); + }); + + test('should support buttonVariant filled', () => { + const { container } = render( + + Radio1 + Radio2 + , + ); + + expect(container.querySelector('.sqi-radio-wrapper-filled')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/radio/__tests__/radio.test.tsx b/packages/sqi-web/src/radio/__tests__/radio.test.tsx new file mode 100644 index 0000000..25e7eda --- /dev/null +++ b/packages/sqi-web/src/radio/__tests__/radio.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import Radio from '../Radio'; +import RadioGroup from '../RadioGroup'; + +describe('Radio', () => { + test('should render correctly', () => { + const { container, getByText } = render(Radio); + expect(container).toBeInTheDocument(); + expect(getByText('Radio')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support defaultChecked', () => { + const { container } = render(Radio); + const input = container.querySelector('input')!; + expect(input).toBeChecked(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support checked', () => { + const { container } = render(Radio); + const input = container.querySelector('input')!; + expect(input).toBeChecked(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support disabled', () => { + const { container } = render(Radio); + const input = container.querySelector('input')!; + expect(input).toBeDisabled(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should trigger onChange when click', () => { + const handleChange = vi.fn(); + const { container } = render(Radio); + const input = container.querySelector('input')!; + + fireEvent.click(input); + expect(handleChange).toBeCalled(); + expect(input).toBeChecked(); + }); + + test('should not trigger onChange when disabled', () => { + const handleChange = vi.fn(); + const { container } = render( + + Radio + , + ); + const input = container.querySelector('input')!; + + fireEvent.click(input); + expect(handleChange).not.toBeCalled(); + expect(input).not.toBeChecked(); + }); + + test('should work in group', () => { + const handleChange = vi.fn(); + const { container, getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should trigger onChange in group', () => { + const handleChange = vi.fn(); + const { container, getAllByRole } = render( + + Radio1 + Radio2 + , + ); + + const radios = getAllByRole('radio'); + fireEvent.click(radios[1]); + + expect(handleChange).toBeCalled(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('should support function as children', () => { + const { container, getByText } = render( + {({ checked }) => {checked ? 'Checked' : 'Unchecked'}}, + ); + + expect(getByText('Checked')).toBeInTheDocument(); + // + expect(container.firstChild?.firstChild).toHaveStyle({ display: 'none' }); + expect(container).toMatchSnapshot(); + }); + + test('should support button style when _IS_BUTTON_ is true', () => { + const { container } = render(Button Radio); + expect(container.querySelector('.sqi-radio-button-wrapper')).toBeInTheDocument(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/space/__tests__/__snapshots__/space.test.tsx.snap b/packages/sqi-web/src/space/__tests__/__snapshots__/space.test.tsx.snap new file mode 100644 index 0000000..d471d7d --- /dev/null +++ b/packages/sqi-web/src/space/__tests__/__snapshots__/space.test.tsx.snap @@ -0,0 +1,308 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Space > applies correct align classes 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies correct direction classes 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies correct direction classes 2`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies correct spacing for different size values 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies correct spacing for different size values 2`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies correct spacing for different size values 3`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > applies wrap class when wrap is true 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > does not apply margin to last item 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > handles array size values for horizontal and vertical spacing 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > handles numeric size values 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > renders children correctly 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > renders with custom className 1`] = ` +
+
+
+ Item 1 +
+
+
+
+ Item 2 +
+
+
+`; + +exports[`Space > renders with split element 1`] = ` +
+
+
+ Item 1 +
+
+ + | + +
+
+ Item 2 +
+
+ + | + +
+
+ Item 3 +
+
+
+`; diff --git a/packages/sqi-web/src/space/__tests__/space.test.tsx b/packages/sqi-web/src/space/__tests__/space.test.tsx new file mode 100644 index 0000000..61febff --- /dev/null +++ b/packages/sqi-web/src/space/__tests__/space.test.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import Space from '..'; + +describe('Space', () => { + it('renders children correctly', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders with custom className', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + expect(container.firstChild).toHaveClass('custom-class'); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('applies correct direction classes', () => { + const { container: horizontalContainer } = render( + +
Item 1
+
Item 2
+
, + ); + + const { container: verticalContainer } = render( + +
Item 1
+
Item 2
+
, + ); + + expect(horizontalContainer.firstChild).toHaveClass('sqi-space-direction-horizontal'); + expect(verticalContainer.firstChild).toHaveClass('sqi-space-direction-vertical'); + expect(horizontalContainer.firstChild).toMatchSnapshot(); + expect(verticalContainer.firstChild).toMatchSnapshot(); + }); + + it('applies correct align classes', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + expect(container.firstChild).toHaveClass('sqi-space-align-start'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('applies wrap class when wrap is true', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + expect(container.firstChild).toHaveClass('sqi-space-wrap'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders with split element', () => { + const { container } = render( + +
Item 1
+
Item 2
+
Item 3
+
, + ); + + expect(container.querySelectorAll('.sqi-space-item-split')).toHaveLength(2); + expect(container.querySelectorAll('.sqi-space-item-split')[0]).toHaveTextContent('|'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('applies correct spacing for different size values', () => { + const { container: smContainer } = render( + +
Item 1
+
Item 2
+
, + ); + + const { container: mdContainer } = render( + +
Item 1
+
Item 2
+
, + ); + + const { container: lgContainer } = render( + +
Item 1
+
Item 2
+
, + ); + + // Check that items have correct margin styles + const smItem = smContainer.querySelector('.space-item'); + const mdItem = mdContainer.querySelector('.space-item'); + const lgItem = lgContainer.querySelector('.space-item'); + + expect(smItem).toHaveStyle('margin-right: 8px'); + expect(mdItem).toHaveStyle('margin-right: 16px'); + expect(lgItem).toHaveStyle('margin-right: 24px'); + + expect(smContainer.firstChild).toMatchSnapshot(); + expect(mdContainer.firstChild).toMatchSnapshot(); + expect(lgContainer.firstChild).toMatchSnapshot(); + }); + + it('handles numeric size values', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + const item = container.querySelector('.space-item'); + expect(item).toHaveStyle('margin-right: 30px'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('handles array size values for horizontal and vertical spacing', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + const item = container.querySelector('.space-item'); + expect(item).toHaveStyle('margin-right: 10px'); + expect(item).toHaveStyle('margin-bottom: 20px'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('does not apply margin to last item', () => { + const { container } = render( + +
Item 1
+
Item 2
+
, + ); + + const items = container.querySelectorAll('.space-item'); + const lastItem = items[items.length - 1]; + expect(lastItem).not.toHaveStyle('margin-right: 16px'); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/switch/__tests__/__snapshots__/switch.test.tsx.snap b/packages/sqi-web/src/switch/__tests__/__snapshots__/switch.test.tsx.snap new file mode 100644 index 0000000..bd39a26 --- /dev/null +++ b/packages/sqi-web/src/switch/__tests__/__snapshots__/switch.test.tsx.snap @@ -0,0 +1,308 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Switch > renders correctly 1`] = ` + +`; + +exports[`Switch > should apply different sizes 1`] = ` + +`; + +exports[`Switch > should apply different sizes 2`] = ` + +`; + +exports[`Switch > should apply different sizes 3`] = ` + +`; + +exports[`Switch > should handle controlled component 1`] = ` + +`; + +exports[`Switch > should handle controlled component 2`] = ` + +`; + +exports[`Switch > should handle controlled component 3`] = ` + +`; + +exports[`Switch > should not trigger change event when disabled 1`] = ` + +`; + +exports[`Switch > should not trigger change event when loading 1`] = ` + +`; + +exports[`Switch > should render custom loading icon when provided 1`] = ` + +`; + +exports[`Switch > should render unchecked label when switch is not checked 1`] = ` + +`; + +exports[`Switch > should render with custom className 1`] = ` + +`; + +exports[`Switch > should render with custom labels 1`] = ` + +`; + +exports[`Switch > should render with defaultChecked 1`] = ` + +`; + +exports[`Switch > should toggle between checked and unchecked states 1`] = ` + +`; + +exports[`Switch > should toggle between checked and unchecked states 2`] = ` + +`; + +exports[`Switch > should trigger change event when clicked 1`] = ` + +`; diff --git a/packages/sqi-web/src/switch/__tests__/switch.test.tsx b/packages/sqi-web/src/switch/__tests__/switch.test.tsx new file mode 100644 index 0000000..39d79d7 --- /dev/null +++ b/packages/sqi-web/src/switch/__tests__/switch.test.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import Switch from '../Switch'; + +describe('Switch', () => { + it('renders correctly', () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render with defaultChecked', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('sqi-switch-checked'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render with custom labels', () => { + const { container } = render(); + + expect(container.firstChild).toHaveTextContent('ON'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should trigger change event when clicked', () => { + const handleChange = vi.fn(); + const { container } = render(); + + fireEvent.click(container.firstChild!); + expect(handleChange).toHaveBeenCalledWith(true, expect.any(Object)); + expect(container.firstChild).toHaveClass('sqi-switch-checked'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should not trigger change event when disabled', () => { + const handleChange = vi.fn(); + const { container } = render(); + + fireEvent.click(container.firstChild!); + expect(handleChange).not.toHaveBeenCalled(); + expect(container.firstChild).toHaveClass('sqi-switch-disabled'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should not trigger change event when loading', () => { + const handleChange = vi.fn(); + const { container } = render(); + + fireEvent.click(container.firstChild!); + expect(handleChange).not.toHaveBeenCalled(); + expect(container.firstChild).toHaveClass('sqi-switch-loading'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should toggle between checked and unchecked states', () => { + const handleChange = vi.fn(); + const { container } = render(); + + // First click - should be checked + fireEvent.click(container.firstChild!); + expect(handleChange).toHaveBeenCalledWith(true, expect.any(Object)); + expect(container.firstChild).toMatchSnapshot(); + + // Second click - should be unchecked + fireEvent.click(container.firstChild!); + expect(handleChange).toHaveBeenCalledWith(false, expect.any(Object)); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render custom loading icon when provided', () => { + const { container } = render(loading
} />); + + expect(container.querySelector('[data-testid="custom-loading"]')).toBeTruthy(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should apply different sizes', () => { + const { container: smContainer } = render(); + const { container: mdContainer } = render(); + const { container: lgContainer } = render(); + + expect(smContainer.firstChild).toHaveClass('sqi-switch-sm'); + expect(smContainer.firstChild).toMatchSnapshot(); + + expect(mdContainer.firstChild).toHaveClass('sqi-switch-md'); + expect(mdContainer.firstChild).toMatchSnapshot(); + + expect(lgContainer.firstChild).toHaveClass('sqi-switch-lg'); + expect(lgContainer.firstChild).toMatchSnapshot(); + }); + + it('should handle controlled component', () => { + const handleChange = vi.fn(); + const { container, rerender } = render(); + + expect(container.firstChild).not.toHaveClass('sqi-switch-checked'); + expect(container.firstChild).toMatchSnapshot(); + + // Click should call onChange but not change state by itself + fireEvent.click(container.firstChild!); + expect(handleChange).toHaveBeenCalledWith(true, expect.any(Object)); + expect(container.firstChild).toMatchSnapshot(); + + // Only when the checked prop changes, the component updates + rerender(); + expect(container.firstChild).toHaveClass('sqi-switch-checked'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render with custom className', () => { + const { container } = render(); + + expect(container.firstChild).toHaveClass('custom-class'); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render unchecked label when switch is not checked', () => { + const { container } = render(); + + expect(container.firstChild).toHaveTextContent('OFF'); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/packages/sqi-web/src/tooltip/Tooltip.tsx b/packages/sqi-web/src/tooltip/Tooltip.tsx index a98de16..634dcaf 100644 --- a/packages/sqi-web/src/tooltip/Tooltip.tsx +++ b/packages/sqi-web/src/tooltip/Tooltip.tsx @@ -18,13 +18,21 @@ const defaultProps: TooltipProps = { const Tooltip = forwardRef((baseProps, ref) => { const { prefixCls, componentConfig } = useContext(ConfigContext); - const { classNames, theme, ...restProps } = useMergeProps(baseProps, defaultProps, componentConfig?.Tooltip); + const { classNames, rootClassName, theme, ...restProps } = useMergeProps( + baseProps, + defaultProps, + componentConfig?.Tooltip, + ); return ( renders with custom arrow and content className 1`] = ` +