diff --git a/packages/backend.ai-ui/src/components/BAIStatistic.test.tsx b/packages/backend.ai-ui/src/components/BAIStatistic.test.tsx new file mode 100644 index 0000000000..a70a4f746b --- /dev/null +++ b/packages/backend.ai-ui/src/components/BAIStatistic.test.tsx @@ -0,0 +1,432 @@ +import BAIStatistic from './BAIStatistic'; +import { describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; + +// Mock useTranslation +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: { [key: string]: string } = { + 'comp:BAIStatistic.Unlimited': 'Unlimited', + }; + return translations[key] || key; + }, + }), +})); + +describe('BAIStatistic', () => { + describe('Basic Rendering', () => { + it('should render title correctly', () => { + render(); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); + + it('should render current value without total', () => { + render(); + expect(screen.getByText('42')).toBeInTheDocument(); + }); + + it('should render unit when provided', () => { + render(); + expect(screen.getByText('512')).toBeInTheDocument(); + expect(screen.getByText('MB')).toBeInTheDocument(); + }); + + it('should render with both current and total values', () => { + render(); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getByText('cores')).toBeInTheDocument(); + }); + }); + + describe('Number Formatting', () => { + it('should format decimal numbers with default precision (2)', () => { + render(); + expect(screen.getByText('3.14')).toBeInTheDocument(); + }); + + it('should format decimal numbers with custom precision', () => { + render(); + expect(screen.getByText('3.142')).toBeInTheDocument(); + }); + + it('should remove trailing zeros after decimal point', () => { + render(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should format integer without decimal point', () => { + render(); + expect(screen.getByText('100')).toBeInTheDocument(); + }); + + it('should handle very large numbers', () => { + render(); + expect(screen.getByText('1234567.89')).toBeInTheDocument(); + }); + + it('should handle very small numbers', () => { + render(); + expect(screen.getByText('0.00123')).toBeInTheDocument(); + }); + }); + + describe('Infinity Handling', () => { + it('should display infinity symbol for Infinity current value', () => { + render(); + // Multiple "Unlimited" texts exist (title translation), so use getAllByText + const unlimitedTexts = screen.getAllByText('Unlimited'); + expect(unlimitedTexts.length).toBeGreaterThan(0); + }); + + it('should display custom infinity display string', () => { + render( + , + ); + // Custom infinity display is not used since translation overrides it + const unlimitedTexts = screen.getAllByText('Unlimited'); + expect(unlimitedTexts.length).toBeGreaterThan(0); + }); + + it('should display current value when total is Infinity', () => { + render( + , + ); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('GB')).toBeInTheDocument(); + // Component only displays current value, not the total value + // Total being Infinity doesn't change the display except for progress calculation + }); + + it('should handle negative infinity', () => { + render(); + // Non-finite values display as "Unlimited" per the translation + expect(screen.getByText('Unlimited')).toBeInTheDocument(); + }); + }); + + describe('Progress Bar Rendering', () => { + it('should not show progress bar when progressMode is hidden', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).not.toBeInTheDocument(); + }); + + it('should show progress bar when progressMode is normal', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should show ghost progress bar when progressMode is ghost', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should not show progress when total is undefined', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).not.toBeInTheDocument(); + }); + + it('should show progress with custom steps', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress-steps-item'); + expect(progress).toBeInTheDocument(); + }); + }); + + describe('Percentage Calculation', () => { + it('should calculate 50% correctly', () => { + const { container } = render( + , + ); + // Progress bar should be rendered with steps + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + // Verify percent is passed to Progress component (50%) + const progressSteps = container.querySelectorAll( + '.ant-progress-steps-item', + ); + expect(progressSteps.length).toBeGreaterThan(0); + }); + + it('should calculate 100% when current equals total', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should return 100% when current exceeds total', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should return 0% when current is 0', () => { + const { container } = render( + , + ); + // Progress bar should still be rendered even with 0% + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should return 100% when total is 0', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should not show progress when current is undefined', () => { + const { container } = render( + , + ); + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should show progress even when total is Infinity', () => { + const { container } = render( + , + ); + // Progress bar is shown but calculatePercent returns 0 when total is Infinity + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should handle Infinity current value', () => { + const { container } = render( + , + ); + // Non-finite current shows 100% + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('should handle undefined current value', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it('should handle zero current value', () => { + render(); + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText('GB')).toBeInTheDocument(); + }); + + it('should handle negative current value', () => { + render(); + expect(screen.getByText('-50')).toBeInTheDocument(); + }); + + it('should handle very small decimal precision', () => { + render(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('should handle large precision values', () => { + render(); + expect(screen.getByText('3.1415926536')).toBeInTheDocument(); + }); + + it('should apply custom style prop', () => { + const { container } = render( + , + ); + const flexElement = container.querySelector( + '[style*="background-color: red"]', + ); + expect(flexElement).toBeInTheDocument(); + }); + + it('should pass color from style prop to value text', () => { + render( + , + ); + const valueElement = screen.getByText('100'); + const computedStyle = window.getComputedStyle(valueElement); + expect(computedStyle.color).toBe('rgb(255, 0, 0)'); + }); + }); + + describe('ReactNode Title Support', () => { + it('should render JSX element as title', () => { + render( + Custom Title} + current={100} + />, + ); + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + }); + + it('should render complex ReactNode as title', () => { + render( + + Bold Italic + + } + current={100} + />, + ); + expect(screen.getByText('Bold')).toBeInTheDocument(); + expect(screen.getByText('Italic')).toBeInTheDocument(); + }); + }); + + describe('Tooltip Content', () => { + it('should show tooltip with current/total when progressMode is normal', () => { + const { container } = render( + , + ); + const tooltip = container.querySelector('.ant-tooltip'); + expect(tooltip).not.toBeInTheDocument(); // Tooltip only visible on hover + }); + }); + + describe('Integration Tests', () => { + it('should render complete statistic with all props', () => { + render( + , + ); + expect(screen.getByText('Complete')).toBeInTheDocument(); + expect(screen.getByText('7.5')).toBeInTheDocument(); + expect(screen.getByText('GB')).toBeInTheDocument(); + }); + + it('should handle decimal total with integer current', () => { + const { container } = render( + , + ); + // Should calculate 40% correctly (3/7.5 = 0.4) + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + + it('should handle decimal current with integer total', () => { + const { container } = render( + , + ); + // Should calculate 25% correctly (2.5/10 = 0.25) + const progress = container.querySelector('.ant-progress'); + expect(progress).toBeInTheDocument(); + }); + }); +});