diff --git a/frontend/src/components/shared/ButtonSelector.test.tsx b/frontend/src/components/shared/ButtonSelector.test.tsx
new file mode 100644
index 000000000..18268aa2b
--- /dev/null
+++ b/frontend/src/components/shared/ButtonSelector.test.tsx
@@ -0,0 +1,185 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import ButtonSelector from './ButtonSelector';
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('ButtonSelector', () => {
+ const mockOnChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render all options as buttons', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ];
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Option 1')).toBeInTheDocument();
+ expect(screen.getByText('Option 2')).toBeInTheDocument();
+ });
+
+ test('should highlight selected button with filled variant', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ];
+
+ render(
+
+
+
+ );
+
+ const selectedButton = screen.getByRole('button', { name: 'Option 1' });
+ const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
+
+ // Check data-variant attribute for filled/outline
+ expect(selectedButton).toHaveAttribute('data-variant', 'filled');
+ expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
+ });
+
+ test('should call onChange when button is clicked', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ];
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
+
+ expect(mockOnChange).toHaveBeenCalledWith('option2');
+ });
+
+ test('should handle undefined value (no selection)', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ];
+
+ render(
+
+
+
+ );
+
+ // Both buttons should be outlined when no value is selected
+ const button1 = screen.getByRole('button', { name: 'Option 1' });
+ const button2 = screen.getByRole('button', { name: 'Option 2' });
+
+ expect(button1).toHaveAttribute('data-variant', 'outline');
+ expect(button2).toHaveAttribute('data-variant', 'outline');
+ });
+
+ test.each([
+ {
+ description: 'disable buttons when disabled prop is true',
+ options: [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ],
+ globalDisabled: true,
+ expectedStates: [true, true],
+ },
+ {
+ description: 'disable individual options when option.disabled is true',
+ options: [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2', disabled: true },
+ ],
+ globalDisabled: false,
+ expectedStates: [false, true],
+ },
+ ])('should $description', ({ options, globalDisabled, expectedStates }) => {
+ render(
+
+
+
+ );
+
+ options.forEach((option, index) => {
+ const button = screen.getByRole('button', { name: option.label });
+ expect(button).toHaveProperty('disabled', expectedStates[index]);
+ });
+ });
+
+ test('should not call onChange when disabled button is clicked', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2', disabled: true },
+ ];
+
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
+
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+
+ test('should not apply fullWidth styling when fullWidth is false', () => {
+ const options = [
+ { value: 'option1', label: 'Option 1' },
+ { value: 'option2', label: 'Option 2' },
+ ];
+
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole('button', { name: 'Option 1' });
+ expect(button).not.toHaveStyle({ flex: '1' });
+ });
+});
diff --git a/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx b/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx
new file mode 100644
index 000000000..92f359abd
--- /dev/null
+++ b/frontend/src/components/tools/redact/RedactAdvancedSettings.test.tsx
@@ -0,0 +1,211 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import RedactAdvancedSettings from './RedactAdvancedSettings';
+import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
+
+// Mock useTranslation
+const mockT = vi.fn((_key: string, fallback: string) => fallback);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('RedactAdvancedSettings', () => {
+ const mockOnParameterChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render all advanced settings controls', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Box Colour')).toBeInTheDocument();
+ expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument();
+ expect(screen.getByText('Use Regex')).toBeInTheDocument();
+ expect(screen.getByText('Whole Word Search')).toBeInTheDocument();
+ expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument();
+ });
+
+ test('should display current parameter values', () => {
+ const customParameters = {
+ ...defaultParameters,
+ redactColor: '#FF0000',
+ customPadding: 0.5,
+ useRegex: true,
+ wholeWordSearch: true,
+ convertPDFToImage: false,
+ };
+
+ render(
+
+
+
+ );
+
+ // Check color input value
+ const colorInput = screen.getByDisplayValue('#FF0000');
+ expect(colorInput).toBeInTheDocument();
+
+ // Check number input value
+ const paddingInput = screen.getByDisplayValue('0.5');
+ expect(paddingInput).toBeInTheDocument();
+
+ // Check checkbox states
+ const useRegexCheckbox = screen.getByLabelText('Use Regex');
+ const wholeWordCheckbox = screen.getByLabelText('Whole Word Search');
+ const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)');
+
+ expect(useRegexCheckbox).toBeChecked();
+ expect(wholeWordCheckbox).toBeChecked();
+ expect(convertCheckbox).not.toBeChecked();
+ });
+
+ test('should call onParameterChange when color is changed', () => {
+ render(
+
+
+
+ );
+
+ const colorInput = screen.getByDisplayValue('#000000');
+ fireEvent.change(colorInput, { target: { value: '#FF0000' } });
+
+ expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
+ });
+
+ test('should call onParameterChange when padding is changed', () => {
+ render(
+
+
+
+ );
+
+ const paddingInput = screen.getByDisplayValue('0.1');
+ fireEvent.change(paddingInput, { target: { value: '0.5' } });
+
+ expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5);
+ });
+
+ test('should handle invalid padding values', () => {
+ render(
+
+
+
+ );
+
+ const paddingInput = screen.getByDisplayValue('0.1');
+
+ // Simulate NumberInput onChange with invalid value (empty string)
+ const numberInput = paddingInput.closest('.mantine-NumberInput-root');
+ if (numberInput) {
+ // Find the input and trigger change with empty value
+ fireEvent.change(paddingInput, { target: { value: '' } });
+
+ // The component should default to 0.1 for invalid values
+ expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1);
+ }
+ });
+
+ test.each([
+ {
+ paramName: 'useRegex' as const,
+ label: 'Use Regex',
+ initialValue: false,
+ expectedValue: true,
+ },
+ {
+ paramName: 'wholeWordSearch' as const,
+ label: 'Whole Word Search',
+ initialValue: false,
+ expectedValue: true,
+ },
+ {
+ paramName: 'convertPDFToImage' as const,
+ label: 'Convert PDF to PDF-Image (Used to remove text behind the box)',
+ initialValue: true,
+ expectedValue: false,
+ },
+ ])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => {
+ const customParameters = {
+ ...defaultParameters,
+ [paramName]: initialValue,
+ };
+
+ render(
+
+
+
+ );
+
+ const checkbox = screen.getByLabelText(label);
+ fireEvent.click(checkbox);
+
+ expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue);
+ });
+
+ test.each([
+ { controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') },
+ { controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') },
+ { controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') },
+ { controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') },
+ { controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') },
+ ])('should disable $controlType when disabled prop is true', ({ getValue }) => {
+ render(
+
+
+
+ );
+
+ const control = getValue();
+ expect(control).toBeDisabled();
+ });
+
+ test('should have correct padding input constraints', () => {
+ render(
+
+
+
+ );
+
+ // NumberInput in Mantine might not expose these attributes directly on the input element
+ // Instead, check that the NumberInput component is rendered with correct placeholder
+ const paddingInput = screen.getByPlaceholderText('0.1');
+ expect(paddingInput).toBeInTheDocument();
+ expect(paddingInput).toHaveDisplayValue('0.1');
+ });
+});
diff --git a/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx b/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx
new file mode 100644
index 000000000..2bfe94e06
--- /dev/null
+++ b/frontend/src/components/tools/redact/RedactSingleStepSettings.test.tsx
@@ -0,0 +1,183 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import RedactSingleStepSettings from './RedactSingleStepSettings';
+import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
+
+// Mock useTranslation
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('RedactSingleStepSettings', () => {
+ const mockOnParameterChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render mode selector', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Mode')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
+ });
+
+ test('should render automatic mode settings when mode is automatic', () => {
+ render(
+
+
+
+ );
+
+ // Default mode is automatic, so these should be visible
+ expect(screen.getByText('Words to Redact')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
+ expect(screen.getByText('Box Colour')).toBeInTheDocument();
+ expect(screen.getByText('Use Regex')).toBeInTheDocument();
+ });
+
+ test('should render manual mode settings when mode is manual', () => {
+ const manualParameters = {
+ ...defaultParameters,
+ mode: 'manual' as const,
+ };
+
+ render(
+
+
+
+ );
+
+ // Manual mode should show placeholder text
+ expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument();
+
+ // Automatic mode settings should not be visible
+ expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument();
+ });
+
+ test('should pass through parameter changes from automatic settings', () => {
+ render(
+
+
+
+ );
+
+ // Test adding a word
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: 'TestWord' } });
+ fireEvent.click(addButton);
+
+ expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']);
+ });
+
+ test('should pass through parameter changes from advanced settings', () => {
+ render(
+
+
+
+ );
+
+ // Test changing color
+ const colorInput = screen.getByDisplayValue('#000000');
+ fireEvent.change(colorInput, { target: { value: '#FF0000' } });
+
+ expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
+ });
+
+ test('should disable all controls when disabled prop is true', () => {
+ render(
+
+
+
+ );
+
+ // Mode selector buttons should be disabled
+ expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled();
+
+ // Automatic settings controls should be disabled
+ expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled();
+ expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled();
+ expect(screen.getByDisplayValue('#000000')).toBeDisabled();
+ });
+
+ test('should show current parameter values in automatic mode', () => {
+ const customParameters = {
+ ...defaultParameters,
+ wordsToRedact: ['Word1', 'Word2'],
+ redactColor: '#FF0000',
+ useRegex: true,
+ customPadding: 0.5,
+ };
+
+ render(
+
+
+
+ );
+
+ // Check that word tags are displayed
+ expect(screen.getByText('Word1')).toBeInTheDocument();
+ expect(screen.getByText('Word2')).toBeInTheDocument();
+
+ // Check that color is displayed
+ expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument();
+
+ // Check that regex checkbox is checked
+ const useRegexCheckbox = screen.getByLabelText('Use Regex');
+ expect(useRegexCheckbox).toBeChecked();
+
+ // Check that padding value is displayed
+ expect(screen.getByDisplayValue('0.5')).toBeInTheDocument();
+ });
+
+ test('should maintain consistent spacing and layout', () => {
+ render(
+
+
+
+ );
+
+ // Check that the Stack container exists
+ const container = screen.getByText('Mode').closest('.mantine-Stack-root');
+ expect(container).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx b/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
new file mode 100644
index 000000000..35bb3dc5d
--- /dev/null
+++ b/frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
@@ -0,0 +1,191 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { MantineProvider } from '@mantine/core';
+import WordsToRedactInput from './WordsToRedactInput';
+
+// Mock useTranslation
+const mockT = vi.fn((_key: string, fallback: string) => fallback);
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: mockT })
+}));
+
+// Wrapper component to provide Mantine context
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('WordsToRedactInput', () => {
+ const mockOnWordsChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should render with title and input field', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Words to Redact')).toBeInTheDocument();
+ expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument();
+ });
+
+ test.each([
+ { trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) },
+ { trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) },
+ ])('should add word when $trigger', ({ action }) => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: 'TestWord' } });
+ action(input, addButton);
+
+ expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
+ });
+
+ test('should not add empty word', () => {
+ render(
+
+
+
+ );
+
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.click(addButton);
+
+ expect(mockOnWordsChange).not.toHaveBeenCalled();
+ });
+
+ test('should not add duplicate word', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: 'Existing' } });
+ fireEvent.click(addButton);
+
+ expect(mockOnWordsChange).not.toHaveBeenCalled();
+ });
+
+ test('should trim whitespace when adding word', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: ' TestWord ' } });
+ fireEvent.click(addButton);
+
+ expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
+ });
+
+ test('should remove word when x button is clicked', () => {
+ render(
+
+
+
+ );
+
+ const removeButtons = screen.getAllByText('×');
+ fireEvent.click(removeButtons[0]);
+
+ expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
+ });
+
+ test('should clear input after adding word', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement;
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: 'TestWord' } });
+ fireEvent.click(addButton);
+
+ expect(input.value).toBe('');
+ });
+
+ test.each([
+ { description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true },
+ { description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false },
+ ])('should $description', ({ inputValue, expectedDisabled }) => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+
+ fireEvent.change(input, { target: { value: inputValue } });
+
+ expect(addButton).toHaveProperty('disabled', expectedDisabled);
+ });
+
+ test('should disable all controls when disabled prop is true', () => {
+ render(
+
+
+
+ );
+
+ const input = screen.getByPlaceholderText('Enter a word');
+ const addButton = screen.getByRole('button', { name: '+ Add' });
+ const removeButton = screen.getByText('×');
+
+ expect(input).toBeDisabled();
+ expect(addButton).toBeDisabled();
+ expect(removeButton.closest('button')).toBeDisabled();
+ });
+});
diff --git a/frontend/src/hooks/tools/redact/useRedactOperation.test.ts b/frontend/src/hooks/tools/redact/useRedactOperation.test.ts
new file mode 100644
index 000000000..5da2bb6ca
--- /dev/null
+++ b/frontend/src/hooks/tools/redact/useRedactOperation.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, test, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation';
+import { defaultParameters, RedactParameters } from './useRedactParameters';
+import { ToolOperationHook } from '../shared/useToolOperation';
+
+// Mock the useToolOperation hook
+vi.mock('../shared/useToolOperation', async () => {
+ const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
+ return {
+ ...actual,
+ useToolOperation: vi.fn()
+ };
+});
+
+// Mock the translation hook
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
+}));
+
+// Mock the error handler utility
+vi.mock('../../../utils/toolErrorHandler', () => ({
+ createStandardErrorHandler: vi.fn(() => vi.fn())
+}));
+
+describe('buildRedactFormData', () => {
+ const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
+
+ test('should build form data for automatic mode', () => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'automatic',
+ wordsToRedact: ['Confidential', 'Secret'],
+ useRegex: true,
+ wholeWordSearch: true,
+ redactColor: '#FF0000',
+ customPadding: 0.5,
+ convertPDFToImage: false,
+ };
+
+ const formData = buildRedactFormData(parameters, mockFile);
+
+ expect(formData.get('fileInput')).toBe(mockFile);
+ expect(formData.get('listOfText')).toBe('Confidential\nSecret');
+ expect(formData.get('useRegex')).toBe('true');
+ expect(formData.get('wholeWordSearch')).toBe('true');
+ expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
+ expect(formData.get('customPadding')).toBe('0.5');
+ expect(formData.get('convertPDFToImage')).toBe('false');
+ });
+
+ test('should handle empty words array', () => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'automatic',
+ wordsToRedact: [],
+ };
+
+ const formData = buildRedactFormData(parameters, mockFile);
+
+ expect(formData.get('listOfText')).toBe('');
+ });
+
+ test('should join multiple words with newlines', () => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'automatic',
+ wordsToRedact: ['Word1', 'Word2', 'Word3'],
+ };
+
+ const formData = buildRedactFormData(parameters, mockFile);
+
+ expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
+ });
+
+ test.each([
+ { description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
+ { description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
+ ])('should $description', ({ redactColor, expected }) => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'automatic',
+ redactColor,
+ };
+
+ const formData = buildRedactFormData(parameters, mockFile);
+
+ expect(formData.get('redactColor')).toBe(expected);
+ });
+
+ test('should convert boolean parameters to strings', () => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'automatic',
+ useRegex: false,
+ wholeWordSearch: true,
+ convertPDFToImage: false,
+ };
+
+ const formData = buildRedactFormData(parameters, mockFile);
+
+ expect(formData.get('useRegex')).toBe('false');
+ expect(formData.get('wholeWordSearch')).toBe('true');
+ expect(formData.get('convertPDFToImage')).toBe('false');
+ });
+
+ test('should throw error for manual mode (not implemented)', () => {
+ const parameters: RedactParameters = {
+ ...defaultParameters,
+ mode: 'manual',
+ };
+
+ expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
+ });
+});
+
+describe('useRedactOperation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('should call useToolOperation with correct configuration', async () => {
+ const { useToolOperation } = await import('../shared/useToolOperation');
+ const mockUseToolOperation = vi.mocked(useToolOperation);
+
+ renderHook(() => useRedactOperation());
+
+ expect(mockUseToolOperation).toHaveBeenCalledWith({
+ ...redactOperationConfig,
+ getErrorMessage: expect.any(Function),
+ });
+ });
+
+ test('should provide error handler to useToolOperation', async () => {
+ const { useToolOperation } = await import('../shared/useToolOperation');
+ const mockUseToolOperation = vi.mocked(useToolOperation);
+
+ renderHook(() => useRedactOperation());
+
+ const callArgs = mockUseToolOperation.mock.calls[0][0];
+ expect(typeof callArgs.getErrorMessage).toBe('function');
+ });
+});
diff --git a/frontend/src/hooks/tools/redact/useRedactParameters.test.ts b/frontend/src/hooks/tools/redact/useRedactParameters.test.ts
new file mode 100644
index 000000000..b87719ad9
--- /dev/null
+++ b/frontend/src/hooks/tools/redact/useRedactParameters.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useRedactParameters, defaultParameters } from './useRedactParameters';
+
+describe('useRedactParameters', () => {
+ test('should initialize with default parameters', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ expect(result.current.parameters).toStrictEqual(defaultParameters);
+ });
+
+ test.each([
+ { paramName: 'mode' as const, value: 'manual' as const },
+ { paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
+ { paramName: 'useRegex' as const, value: true },
+ { paramName: 'wholeWordSearch' as const, value: true },
+ { paramName: 'redactColor' as const, value: '#FF0000' },
+ { paramName: 'customPadding' as const, value: 0.5 },
+ { paramName: 'convertPDFToImage' as const, value: false }
+ ])('should update parameter $paramName', ({ paramName, value }) => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter(paramName, value);
+ });
+
+ expect(result.current.parameters[paramName]).toStrictEqual(value);
+ });
+
+ test('should reset parameters to defaults', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ // Modify some parameters
+ act(() => {
+ result.current.updateParameter('mode', 'manual');
+ result.current.updateParameter('wordsToRedact', ['test']);
+ result.current.updateParameter('useRegex', true);
+ });
+
+ // Reset parameters
+ act(() => {
+ result.current.resetParameters();
+ });
+
+ expect(result.current.parameters).toStrictEqual(defaultParameters);
+ });
+
+ describe('validation', () => {
+ test.each([
+ { description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
+ { description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
+ { description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
+ { description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
+ ])('should $description', ({ wordsToRedact, expected }) => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('mode', 'automatic');
+ result.current.updateParameter('wordsToRedact', wordsToRedact);
+ });
+
+ expect(result.current.validateParameters()).toBe(expected);
+ });
+
+ test('should not validate in manual mode (not implemented)', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('mode', 'manual');
+ });
+
+ expect(result.current.validateParameters()).toBe(false);
+ });
+ });
+
+ describe('endpoint handling', () => {
+ test('should return correct endpoint for automatic mode', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('mode', 'automatic');
+ });
+
+ expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
+ });
+
+ test('should throw error for manual mode (not implemented)', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('mode', 'manual');
+ });
+
+ expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
+ });
+ });
+
+ test('should maintain parameter state across updates', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('redactColor', '#FF0000');
+ result.current.updateParameter('customPadding', 0.5);
+ result.current.updateParameter('wordsToRedact', ['word1']);
+ });
+
+ // All parameters should be updated
+ expect(result.current.parameters.redactColor).toBe('#FF0000');
+ expect(result.current.parameters.customPadding).toBe(0.5);
+ expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
+
+ // Other parameters should remain at defaults
+ expect(result.current.parameters.mode).toBe('automatic');
+ expect(result.current.parameters.useRegex).toBe(false);
+ expect(result.current.parameters.wholeWordSearch).toBe(false);
+ expect(result.current.parameters.convertPDFToImage).toBe(true);
+ });
+
+ test('should handle array parameter updates correctly', () => {
+ const { result } = renderHook(() => useRedactParameters());
+
+ act(() => {
+ result.current.updateParameter('wordsToRedact', ['initial']);
+ });
+
+ expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
+
+ act(() => {
+ result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
+ });
+
+ expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
+ });
+});