wrolpi/app/src/test-utils.js
Roland 74cb5171da
FileGroups in Collections can be reorganized in the Collection's directory.
* Batch reorganization is supported for both Domain and Channel Collections.
* Batch reorganization happens when any Collection does not match the respective file format. For example, a Channel Collection file format that does not match the `yt_dlp_options.file_name_format` will show `needs_reorganization`.
* Individual Collections can always be reorganized. The reorganization is a little more aggressive. Useful for when some files may have been missed in the batch reorganization.
2026-01-31 18:08:41 -07:00

303 lines
8.8 KiB
JavaScript

/**
* Test utilities for React Testing Library
* Provides custom render functions with necessary context providers
*/
import React, {createContext, useContext} from 'react';
import {render} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {MediaContextProvider, ThemeContext} from './contexts/contexts';
// Mock FileWorkerStatusContext for tests - avoids API calls during testing
const MockFileWorkerStatusContext = createContext(null);
function MockFileWorkerStatusProvider({children}) {
const mockValue = {
status: null,
error: null,
refresh: jest.fn(),
setFastPolling: jest.fn(),
};
return (
<MockFileWorkerStatusContext.Provider value={mockValue}>
{children}
</MockFileWorkerStatusContext.Provider>
);
}
// Re-export hooks that use the mock context
export function useFileWorkerStatus() {
const context = useContext(MockFileWorkerStatusContext);
if (!context) {
throw new Error('useFileWorkerStatus must be used within FileWorkerStatusProvider');
}
return context;
}
export function useReorganizationStatus() {
const {status: progress, refresh} = useFileWorkerStatus();
return {
isReorganizing: false,
taskType: null,
collectionId: null,
collectionKind: null,
batchStatus: null,
workerStatus: progress,
refresh,
};
}
/**
* Custom render function that wraps components with necessary providers
* @param {React.Component} ui - Component to render
* @param {Object} options - Render options
* @param {boolean} options.inverted - Whether to use dark theme
* @param {Object} options.themeContext - Custom theme context values
* @param {boolean} options.withMedia - Include MediaContextProvider (default: false)
* @param {Object} options.renderOptions - Additional React Testing Library options
*/
export function renderWithProviders(
ui,
{
inverted = false,
themeContext = {},
withMedia = false,
...renderOptions
} = {}
) {
const defaultThemeContext = {
inverted,
setInverted: jest.fn(),
// Theme components use different properties:
i: inverted ? {inverted: true} : {inverted: undefined}, // For Semantic UI elements (Segment, Form, etc.)
s: inverted ? {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}} : {}, // For style inversion
t: inverted ? {style: {color: '#eeeeee'}} : {}, // For text color inversion (Header, etc.)
theme: inverted ? 'dark' : 'light',
...themeContext
};
function Wrapper({children}) {
const content = (
<BrowserRouter>
<MockFileWorkerStatusProvider>
<ThemeContext.Provider value={defaultThemeContext}>
{children}
</ThemeContext.Provider>
</MockFileWorkerStatusProvider>
</BrowserRouter>
);
// Only wrap with MediaContextProvider if explicitly requested
// (since it requires window.matchMedia which can be tricky in tests)
if (withMedia) {
return <MediaContextProvider>{content}</MediaContextProvider>;
}
return content;
}
return render(ui, {wrapper: Wrapper, ...renderOptions});
}
/**
* Creates a mock domain collection object for testing
*/
export function createMockDomain(overrides = {}) {
return {
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: null,
directory: '',
can_be_tagged: false,
description: '',
...overrides
};
}
/**
* Creates multiple mock domains for list testing
*/
export function createMockDomains(count = 3) {
return Array.from({length: count}, (_, i) => createMockDomain({
id: i + 1,
domain: `example${i + 1}.com`,
archive_count: (i + 1) * 10,
size: (i + 1) * 1000000,
}));
}
/**
* Mock fetch implementation for API calls
*/
export function mockFetch(data, options = {}) {
const {
ok = true,
status = 200,
delay = 0,
} = options;
return jest.fn(() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
});
}, delay);
})
);
}
/**
* Mock API error response
*/
export function mockFetchError(error = 'An error occurred', status = 400) {
return jest.fn(() =>
Promise.resolve({
ok: false,
status,
json: async () => ({error}),
text: async () => error,
})
);
}
/**
* Wait for async updates to complete
* Useful for testing loading states
*/
export async function waitForLoadingToFinish() {
const {waitFor} = await import('@testing-library/react');
await waitFor(() => {
}, {timeout: 100});
}
/**
* Test helper to render components in dark mode
*
* Usage:
* renderInDarkMode(<MyComponent />)
*
* Verify inverted styling is applied:
* const element = container.querySelector('.ui.segment.inverted');
* expect(element).toBeInTheDocument();
*/
export function renderInDarkMode(ui, options = {}) {
return renderWithProviders(ui, {
inverted: true,
...options
});
}
/**
* Test helper to render components in light mode
* (This is the default, but provided for explicitness in tests)
*/
export function renderInLightMode(ui, options = {}) {
return renderWithProviders(ui, {
inverted: false,
...options
});
}
/**
* Helper to check if an element has theme-aware (inverted) styling
* Returns true if the element has the 'inverted' class
*
* Usage:
* const segment = container.querySelector('.ui.segment');
* expect(hasInvertedStyling(segment)).toBe(true);
*/
export function hasInvertedStyling(element) {
return element && element.classList.contains('inverted');
}
/**
* Creates a test-friendly form object using real useForm hook
*
* This uses the actual useForm implementation, making tests more reliable.
* The form starts in a "ready" state with the provided data.
*
* Usage:
* const form = createTestForm({domain: 'test.com', directory: '/path'});
* render(<CollectionEditForm form={form} metadata={metadata} />);
*
* // With overrides:
* const form = createTestForm(data, {overrides: {loading: true, disabled: true}});
*/
export function createTestForm(initialData = {}, config = {}) {
// Return a plain object that mimics the useForm interface without actual React hooks
// This avoids async state updates that cause act() warnings in tests
const _ = require('lodash');
const formData = {...initialData};
const setValue = (path, newValue) => {
_.set(formData, path, newValue);
};
const form = {
formData,
ready: true,
loading: false,
disabled: false,
dirty: false,
errors: {},
// Methods that update formData directly
patchFormData: jest.fn((updates) => {
Object.assign(formData, updates);
}),
reset: jest.fn(() => {
Object.keys(formData).forEach(key => delete formData[key]);
Object.assign(formData, initialData);
}),
onSubmit: config.submitter || jest.fn(async () => initialData),
// Input helpers
setError: jest.fn(),
setValidator: jest.fn(),
setValidValue: jest.fn(),
setRequired: jest.fn(),
// Property getter for field values
get: (path) => _.get(formData, path),
setValue: jest.fn((path, value) => setValue(path, value)),
// getCustomProps - mimics the real useForm method
getCustomProps: ({name, path, required = false, type = 'text'}) => {
path = path || name;
const value = _.get(formData, path);
const inputProps = {
type,
disabled: form.disabled,
value: value !== undefined ? value : (type === 'array' ? [] : null),
onChange: (newValue) => {
setValue(path, newValue);
},
'data-path': path,
};
const inputAttrs = {
valid: true,
path,
localSetValue: (newValue) => setValue(path, newValue),
};
return [inputProps, inputAttrs];
},
};
// Apply any overrides
if (config.overrides) {
Object.assign(form, config.overrides);
}
return form;
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// Override the default render with our custom one
export {renderWithProviders as render};