mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Filtering media was keyed on `data-theme="night"`, which made it a property of
the theme that no user could turn off. Some people want their media tinted to
match the interface; some want to see the actual image.
A theme now declares a filter and whether it starts on, and the user decides:
night night-red on by default. An unfiltered thumbnail undoes dark
adaptation, which is the whole point of the mode.
amber amber-mono off by default. Amber is a look, not a night-vision
aid, so media keeps its real colors until asked for.
light - no filter offered, so no toggle is shown.
dark -
The choice is stored per theme, so wanting amber's tint says nothing about
wanting night's. The toggle lives in the theme picker, which puts it on the
Settings page and in the gallery at once.
Mechanically the rules now key on a `data-media-filter` attribute rather than
`data-theme`: a rule keyed on the theme could not be turned off. index.html
stamps that attribute before first paint alongside data-theme -- without it,
night mode paints a screen of unfiltered thumbnails while the bundle arrives
over a hotspot, costing the user the adaptation the theme exists to protect.
Tests cover both the attribute rule and the fact that the inline script's
duplicated ids and defaults still agree with names.ts.
The amber matrix is the same luminance collapse tinted to #ff9500 (red full,
green 0.584, blue zero) with a gentler gamma than night's: amber carries far
more luminance and clips to a flat wash otherwise.
The gallery gains a Media filtering section showing the WROLPi logo filtered,
beside an unfiltered copy that opts out with `night-unfiltered`. That copy is
behind a click on purpose -- an unfiltered image is a bright patch, and night
mode should not spring one on someone reading in the dark.
ui-design.md gains the media filtering contract it never got in Phase 0,
including the note that a Phase 3 custom theme names an existing filter id and
cannot ship its own matrix, since that would be code rather than data.
324 lines
9.7 KiB
JavaScript
324 lines
9.7 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 {MantineProvider} from '@mantine/core';
|
|
import {MediaContextProvider, ThemeContext} from './contexts/contexts';
|
|
import {cssVariablesResolver, mantineTheme} from './themes/mantine';
|
|
|
|
// 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',
|
|
isDark: inverted,
|
|
savedTheme: null,
|
|
// Media filtering is off by default here; a test that needs it sets these.
|
|
mediaFilter: undefined,
|
|
mediaFilterEnabled: false,
|
|
setMediaFilterEnabled: jest.fn(),
|
|
setTheme: jest.fn(),
|
|
setDarkTheme: jest.fn(),
|
|
setLightTheme: jest.fn(),
|
|
cycleSavedTheme: jest.fn(),
|
|
...themeContext
|
|
};
|
|
|
|
function Wrapper({children}) {
|
|
// MantineProvider, configured exactly as ThemeProvider configures it: components
|
|
// from src/components/ui render Mantine internals and need it in the tree.
|
|
const content = (
|
|
<BrowserRouter>
|
|
<MantineProvider
|
|
theme={mantineTheme}
|
|
cssVariablesResolver={cssVariablesResolver}
|
|
forceColorScheme={inverted ? 'dark' : 'light'}
|
|
>
|
|
<MockFileWorkerStatusProvider>
|
|
<ThemeContext.Provider value={defaultThemeContext}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
</MockFileWorkerStatusProvider>
|
|
</MantineProvider>
|
|
</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,
|
|
error: null,
|
|
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};
|