Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
949 changes: 945 additions & 4 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@
"devDependencies": {
"@electron/rebuild": "^4.0.3",
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/crypto-js": "^4.2.2",
"@types/d3-sankey": "^0.12.5",
"@types/node": "^24.10.1",
Expand All @@ -111,6 +114,8 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jest-axe": "^10.0.0",
"jsdom": "^29.0.0",
"png2icons": "^2.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
Expand Down
87 changes: 87 additions & 0 deletions src/components/_shared/controls/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, act, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button', () => {
it('renders children', () => {
render(<Button>Save</Button>);
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});

it('defaults to primary variant and medium size', () => {
render(<Button>Click me</Button>);
const btn = screen.getByRole('button');
expect(btn).toHaveClass('btn-primary');
expect(btn).not.toHaveClass('btn-small');
});

it.each([
'primary',
'secondary',
'tertiary',
'danger',
'utility',
] as const)('applies %s variant class', (variant) => {
render(<Button variant={variant}>btn</Button>);
expect(screen.getByRole('button')).toHaveClass(`btn-${variant}`);
});

it.each(['xsmall', 'small', 'large'] as const)('applies %s size class', (size) => {
render(<Button size={size}>btn</Button>);
expect(screen.getByRole('button')).toHaveClass(`btn-${size}`);
});

it('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('is disabled when disabled prop is set', () => {
render(<Button disabled>Can't click</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});

it('is disabled and shows loading text when isLoading', () => {
render(<Button isLoading loadingText="Saving...">Save</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent('Saving...');
});

it('does not call onClick when disabled', async () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Blocked</Button>);
await userEvent.click(screen.getByRole('button'), { skipPointerEventsCheck: true });

Check failure on line 57 in src/components/_shared/controls/Button/Button.test.tsx

View workflow job for this annotation

GitHub Actions / build

Object literal may only specify known properties, but 'skipPointerEventsCheck' does not exist in type 'DirectOptions'. Did you mean to write 'pointerEventsCheck'?
expect(handleClick).not.toHaveBeenCalled();
});

it('shows successText after click and resets after timeout', () => {
vi.useFakeTimers();
try {
render(<Button successText="Copied!">Copy</Button>);
const btn = screen.getByRole('button');
expect(btn).toHaveTextContent('Copy');
fireEvent.click(btn);
expect(btn).toHaveTextContent('Copied!');
act(() => { vi.advanceTimersByTime(2001); });
expect(btn).toHaveTextContent('Copy');
} finally {
vi.useRealTimers();
}
});

it('applies additional className', () => {
render(<Button className="extra">btn</Button>);
expect(screen.getByRole('button')).toHaveClass('extra');
});

it('forwards additional HTML button attributes', () => {
render(<Button type="submit" aria-label="Submit form">Submit</Button>);
const btn = screen.getByRole('button');
expect(btn).toHaveAttribute('type', 'submit');
expect(btn).toHaveAttribute('aria-label', 'Submit form');
});
});
76 changes: 76 additions & 0 deletions src/components/_shared/controls/FormGroup/FormGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';

Check failure on line 1 in src/components/_shared/controls/FormGroup/FormGroup.test.tsx

View workflow job for this annotation

GitHub Actions / test

'vi' is defined but never used

Check failure on line 1 in src/components/_shared/controls/FormGroup/FormGroup.test.tsx

View workflow job for this annotation

GitHub Actions / build

'vi' is declared but its value is never read.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

Check failure on line 3 in src/components/_shared/controls/FormGroup/FormGroup.test.tsx

View workflow job for this annotation

GitHub Actions / test

'userEvent' is defined but never used

Check failure on line 3 in src/components/_shared/controls/FormGroup/FormGroup.test.tsx

View workflow job for this annotation

GitHub Actions / build

'userEvent' is declared but its value is never read.
import FormGroup from './FormGroup';

describe('FormGroup', () => {
it('renders children', () => {
render(
<FormGroup>
<input type="text" placeholder="Name" />
</FormGroup>,
);
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument();
});

it('renders label text', () => {
render(<FormGroup label="Email"><input /></FormGroup>);
expect(screen.getByText('Email')).toBeInTheDocument();
});

it('shows required asterisk when required is set', () => {
render(<FormGroup label="Name" required><input /></FormGroup>);
expect(screen.getByText('*')).toBeInTheDocument();
});

it('does not render label when label prop is omitted', () => {
const { container } = render(<FormGroup><input /></FormGroup>);
expect(container.querySelector('label')).toBeNull();
});

it('displays error message', () => {
render(<FormGroup error="This field is required"><input /></FormGroup>);
expect(screen.getByText('This field is required')).toBeInTheDocument();
});

it('displays warning when no error is present', () => {
render(<FormGroup warning="Value seems low"><input /></FormGroup>);
expect(screen.getByText('Value seems low')).toBeInTheDocument();
});

it('error takes priority over warning', () => {
render(<FormGroup error="Required" warning="Seems low"><input /></FormGroup>);
expect(screen.getByText('Required')).toBeInTheDocument();
expect(screen.queryByText('Seems low')).toBeNull();
});

it('displays helper text when no error and no warning', () => {
render(<FormGroup helperText="Enter your full name"><input /></FormGroup>);
expect(screen.getByText('Enter your full name')).toBeInTheDocument();
});

it('hides helper text when error is present', () => {
render(<FormGroup error="Required" helperText="Enter name"><input /></FormGroup>);
expect(screen.queryByText('Enter name')).toBeNull();
});

it('error message has "error" class', () => {
render(<FormGroup error="Bad input"><input /></FormGroup>);
expect(screen.getByText('Bad input')).toHaveClass('error');
});

it('warning message has "warning" class', () => {
render(<FormGroup warning="Watch out"><input /></FormGroup>);
expect(screen.getByText('Watch out')).toHaveClass('warning');
});

it('helper text has "helper-text" class', () => {
render(<FormGroup helperText="Hint"><input /></FormGroup>);
expect(screen.getByText('Hint')).toHaveClass('helper-text');
});

it('does not show required asterisk when required is not set', () => {
render(<FormGroup label="Name"><input /></FormGroup>);
expect(screen.queryByText('*')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormattedNumberInput from './FormattedNumberInput';

describe('FormattedNumberInput', () => {
it('renders an input element', () => {
render(<FormattedNumberInput value={100} onChange={vi.fn()} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});

it('displays formatted value when blurred', () => {
render(<FormattedNumberInput value={1234.56} onChange={vi.fn()} />);
expect(screen.getByRole('textbox')).toHaveValue('1,234.56');
});

it('shows an empty string for empty value', () => {
render(<FormattedNumberInput value="" onChange={vi.fn()} />);
expect(screen.getByRole('textbox')).toHaveValue('');
});

it('calls onChange when user types a digit', async () => {
const handleChange = vi.fn();
render(<FormattedNumberInput value="" onChange={handleChange} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, '5');
expect(handleChange).toHaveBeenCalled();
const firstCall = handleChange.mock.calls[0][0];
expect(firstCall.target.value).toBe('5');
});

it('strips non-numeric characters from each onChange call', async () => {
const handleChange = vi.fn();
render(<FormattedNumberInput value="" onChange={handleChange} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, 'a');
// non-numeric chars produce sanitized empty string
const allCallValues: string[] = handleChange.mock.calls.map((c) => c[0].target.value);
expect(allCallValues.every((v) => /^-?\d*\.?\d*$/.test(v))).toBe(true);
});

it('renders prefix when provided', () => {
const { container } = render(
<FormattedNumberInput value={50} onChange={vi.fn()} prefix="$" />,
);
expect(container.querySelector('.formatted-number-affix')).toHaveTextContent('$');
});

it('renders suffix when provided', () => {
const { container } = render(
<FormattedNumberInput value={50} onChange={vi.fn()} suffix="%" />,
);
const affixes = container.querySelectorAll('.formatted-number-affix');
const suffixEl = Array.from(affixes).find((el) => el.textContent === '%');
expect(suffixEl).toBeDefined();
});

it('does not render affix elements when prefix/suffix are absent', () => {
const { container } = render(<FormattedNumberInput value={50} onChange={vi.fn()} />);
expect(container.querySelector('.formatted-number-affix')).toBeNull();
});

it('applies field-error class to wrapper when className includes field-error', () => {
const { container } = render(
<FormattedNumberInput value={0} onChange={vi.fn()} className="field-error" />,
);
expect(container.firstChild).toHaveClass('field-error');
});

it('forwards placeholder attribute', () => {
render(
<FormattedNumberInput value="" onChange={vi.fn()} placeholder="Enter amount" />,
);
expect(screen.getByPlaceholderText('Enter amount')).toBeInTheDocument();
});

it('does not allow negative values by default', async () => {
const handleChange = vi.fn();
render(<FormattedNumberInput value="" onChange={handleChange} allowNegative={false} />);
const input = screen.getByRole('textbox');
await userEvent.type(input, '-');
const callValues: string[] = handleChange.mock.calls.map((c) => c[0].target.value);
expect(callValues.every((v) => !v.startsWith('-'))).toBe(true);
});

it('allows negative values when allowNegative is true', async () => {
const handleChange = vi.fn();
render(<FormattedNumberInput value="-" onChange={handleChange} allowNegative={true} />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
51 changes: 51 additions & 0 deletions src/components/_shared/controls/PillBadge/PillBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import PillBadge from './PillBadge';

describe('PillBadge', () => {
it('renders children', () => {
render(<PillBadge>Active</PillBadge>);
expect(screen.getByText('Active')).toBeInTheDocument();
});

it('defaults to neutral variant', () => {
const { container } = render(<PillBadge>text</PillBadge>);
expect(container.firstChild).toHaveClass('pill-badge--neutral');
});

it.each([
'success',
'accent',
'info',
'warning',
'neutral',
'outline',
] as const)('applies %s variant class', (variant) => {
const { container } = render(<PillBadge variant={variant}>label</PillBadge>);
expect(container.firstChild).toHaveClass(`pill-badge--${variant}`);
});

it('applies base pill-badge class', () => {
const { container } = render(<PillBadge>badge</PillBadge>);
expect(container.firstChild).toHaveClass('pill-badge');
});

it('merges additional className', () => {
const { container } = render(<PillBadge className="custom">badge</PillBadge>);
expect(container.firstChild).toHaveClass('custom');
});

it('renders as a span element', () => {
const { container } = render(<PillBadge>badge</PillBadge>);
expect(container.firstChild?.nodeName).toBe('SPAN');
});

it('renders React node children', () => {
render(
<PillBadge>
<strong>Bold</strong>
</PillBadge>,
);
expect(screen.getByText('Bold').tagName).toBe('STRONG');
});
});
70 changes: 70 additions & 0 deletions src/components/_shared/controls/PillToggle/PillToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import PillToggle from './PillToggle';

describe('PillToggle', () => {
it('renders left and right labels', () => {
render(<PillToggle value={false} onChange={vi.fn()} leftLabel="Off" rightLabel="On" />);
expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'On' })).toBeInTheDocument();
});

it('defaults left label to "Off" and right label to "On"', () => {
render(<PillToggle value={false} onChange={vi.fn()} />);
expect(screen.getByText('Off')).toBeInTheDocument();
expect(screen.getByText('On')).toBeInTheDocument();
});

it('calls onChange(true) when right button clicked and value is false', async () => {
const handleChange = vi.fn();
render(<PillToggle value={false} onChange={handleChange} leftLabel="No" rightLabel="Yes" />);
await userEvent.click(screen.getByRole('button', { name: 'Yes' }));
expect(handleChange).toHaveBeenCalledWith(true);
});

it('calls onChange(false) when left button clicked and value is true', async () => {
const handleChange = vi.fn();
render(<PillToggle value={true} onChange={handleChange} leftLabel="No" rightLabel="Yes" />);
await userEvent.click(screen.getByRole('button', { name: 'No' }));
expect(handleChange).toHaveBeenCalledWith(false);
});

it('marks right option as active when value is true', () => {
render(<PillToggle value={true} onChange={vi.fn()} leftLabel="No" rightLabel="Yes" />);
expect(screen.getByRole('button', { name: 'Yes' })).toHaveClass('active');
expect(screen.getByRole('button', { name: 'No' })).not.toHaveClass('active');
});

it('marks left option as active when value is false', () => {
render(<PillToggle value={false} onChange={vi.fn()} leftLabel="No" rightLabel="Yes" />);
expect(screen.getByRole('button', { name: 'No' })).toHaveClass('active');
expect(screen.getByRole('button', { name: 'Yes' })).not.toHaveClass('active');
});

it('disables both buttons when disabled', () => {
render(<PillToggle value={false} onChange={vi.fn()} disabled />);
const buttons = screen.getAllByRole('button');
expect(buttons[0]).toBeDisabled();
expect(buttons[1]).toBeDisabled();
});

it('does not call onChange when disabled', async () => {
const handleChange = vi.fn();
render(<PillToggle value={false} onChange={handleChange} disabled />);
for (const btn of screen.getAllByRole('button')) {
await userEvent.click(btn, { skipPointerEventsCheck: true });

Check failure on line 56 in src/components/_shared/controls/PillToggle/PillToggle.test.tsx

View workflow job for this annotation

GitHub Actions / build

Object literal may only specify known properties, but 'skipPointerEventsCheck' does not exist in type 'DirectOptions'. Did you mean to write 'pointerEventsCheck'?
}
expect(handleChange).not.toHaveBeenCalled();
});

it('applies disabled class to wrapper when disabled', () => {
const { container } = render(<PillToggle value={false} onChange={vi.fn()} disabled />);
expect(container.firstChild).toHaveClass('disabled');
});

it('applies additional className to wrapper', () => {
const { container } = render(<PillToggle value={false} onChange={vi.fn()} className="my-toggle" />);
expect(container.firstChild).toHaveClass('my-toggle');
});
});
Loading
Loading