import React from 'react'; import fs from 'fs'; import path from 'path'; import {act, render, screen, waitFor} from '../test-utils'; import userEvent from '@testing-library/user-event'; import { CardLink, contrastingColor, contrastRatio, DirectorySearch, ExternalCardLink, HelpHeader, InfoHeader, loadRole, PageContainer, SearchResultsInput, TAG_TEXT_DARK, TAG_TEXT_LIGHT, } from './Common'; import {healthRole} from './admin/ControllerPage'; // Mock debounce to make tests run faster jest.mock('lodash/debounce', () => jest.fn(fn => { fn.cancel = jest.fn(); return fn; })); // Mock useSearchDirectories hook to avoid async state updates that cause act() warnings const mockSetDirectoryName = jest.fn(); let mockHookState = { directoryName: '', directories: [], channelDirectories: [], domainDirectories: [], isDir: false, loading: false, }; jest.mock('../hooks/customHooks', () => ({ ...jest.requireActual('../hooks/customHooks'), useSearchDirectories: (value) => { // Return current mock state - tests control state via setMockHookState return { ...mockHookState, setDirectoryName: (newValue) => { mockHookState.directoryName = newValue; mockSetDirectoryName(newValue); }, }; }, })); describe('DirectorySearch', () => { const mockOnSelect = jest.fn(); const mockSearchResults = { directories: [ {path: 'videos/nature'}, {path: 'videos/tech'} ], channelDirectories: [ {path: 'videos/channels/news', name: 'News Channel'} ], domainDirectories: [ {path: 'archive/example.com', domain: 'example.com'} ], }; // Helper to reset mock hook state with specific values const setMockHookState = (overrides = {}) => { mockHookState = { directoryName: '', directories: [], channelDirectories: [], domainDirectories: [], isDir: false, loading: false, ...overrides, }; }; beforeEach(() => { jest.clearAllMocks(); // Reset mock hook state with default search results setMockHookState({ ...mockSearchResults, isDir: false, }); }); describe('Rendering', () => { it('renders with placeholder text', () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); expect(input).toBeInTheDocument(); }); it('shows initial value when provided', () => { setMockHookState({ ...mockSearchResults, directoryName: 'videos/test', }); render(); const input = screen.getByDisplayValue('videos/test'); expect(input).toBeInTheDocument(); }); it('applies disabled state correctly', () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); expect(input).toBeDisabled(); }); it('renders the dropdown without crashing when directoryName is null', async () => { // An unset form field (e.g. the new Channel page) passes a null value. With // isDir=false a "New Directory" result is built from directoryName; if that title is // null, the old Search crashed reading null.length when the menu opened. setMockHookState({ directories: [], channelDirectories: [], domainDirectories: [], isDir: false, directoryName: null, }); render(); const input = screen.getByPlaceholderText(/search directory names/i); // Opening the menu (as Tab/focus does in the browser) must not throw. await userEvent.click(input); await waitFor(() => { expect(screen.getByText(/New Directory/i)).toBeInTheDocument(); }); }); it('displays with required indicator', () => { render( ); // Unlike the old Search, the SearchBox forwards `required` straight // onto the input. const input = screen.getByPlaceholderText(/search directory names/i); expect(input).toBeRequired(); }); }); describe('Search Functionality', () => { it('triggers setDirectoryName on value change', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.type(input, 'videos'); // Verify setDirectoryName was called (via mocked hook) expect(mockSetDirectoryName).toHaveBeenCalled(); // Debounce is mocked, so each character triggers a call // Verify it was called 6 times (one per character in "videos") expect(mockSetDirectoryName.mock.calls.length).toBe(6); }); it('shows loading indicator when loading state is true', () => { setMockHookState({ ...mockSearchResults, loading: true, }); const {container} = render(); // SearchBox renders a Loader alongside the input while `loading` is true. expect(container.querySelector('.wrolpi-searchbox-loading')).toBeInTheDocument(); }); it('hides loading indicator when loading state is false', () => { setMockHookState({ ...mockSearchResults, loading: false, }); const {container} = render(); expect(container.querySelector('.wrolpi-searchbox-loading')).not.toBeInTheDocument(); }); it('displays categorized results', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); // Click to open dropdown await userEvent.click(input); await waitFor(() => { // Should show category names expect(screen.getAllByText(/Directories/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Channels/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Domains/i).length).toBeGreaterThan(0); }); }); it('shows "New Directory" when path doesn\'t exist (isDir=false)', async () => { setMockHookState({ directories: [], channelDirectories: [], domainDirectories: [], isDir: false, directoryName: 'new/path', }); render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.click(input); await waitFor(() => { expect(screen.getByText(/New Directory/i)).toBeInTheDocument(); }); }); it('hides "New Directory" when path exists (isDir=true)', async () => { setMockHookState({ directories: [{path: 'videos/nature/wildlife'}], channelDirectories: [], domainDirectories: [], isDir: true, directoryName: 'videos/nature', }); render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.click(input); // "New Directory" should not appear when is_dir=true expect(screen.queryByText(/New Directory/i)).not.toBeInTheDocument(); }); it('debounces rapid typing (verifies setDirectoryName is called)', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); // Type rapidly await userEvent.type(input, 'abc', {delay: 10}); // Verify setDirectoryName was called expect(mockSetDirectoryName).toHaveBeenCalled(); }); }); describe('User Interactions', () => { it('calls onSelect when result is clicked', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.click(input); await waitFor(() => { expect(screen.getByText('videos/nature')).toBeInTheDocument(); }); // Click on a result const result = screen.getByText('videos/nature'); await userEvent.click(result); expect(mockOnSelect).toHaveBeenCalledWith('videos/nature'); }); it('commits typed value on blur when directoryName differs from value', async () => { // Set up mock state where directoryName differs from the prop value // This simulates what happens after user types in the input setMockHookState({ ...mockSearchResults, directoryName: 'typed/path', // User has typed this }); // Render with empty value prop (different from directoryName) render(); // The SearchBox wires `onBlur` straight to the input (there is no longer a // wrapping Search component to blur instead). const input = screen.getByPlaceholderText(/search directory names/i); // Blur the input - should trigger onBlur which calls onSelect await act(async () => { const {fireEvent} = require('../test-utils'); fireEvent.blur(input); }); // Should call onSelect with directoryName from hook state expect(mockOnSelect).toHaveBeenCalledWith('typed/path'); }); it('does not call onSelect on blur if value unchanged', async () => { setMockHookState({ ...mockSearchResults, directoryName: 'existing/path', }); render(); const input = screen.getByDisplayValue('existing/path'); // Blur without changing value await act(async () => { input.blur(); }); // Should not call onSelect since value didn't change expect(mockOnSelect).not.toHaveBeenCalled(); }); it('disabled state prevents interactions', () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); // Input should be disabled expect(input).toBeDisabled(); }); it('handles rapid selection changes', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.click(input); await waitFor(() => { expect(screen.getByText('videos/nature')).toBeInTheDocument(); }); // Click multiple results in succession. Choosing a result closes the // dropdown (as any combobox would) without moving focus off the input // (the option is chosen on mousedown, with the default prevented), so // pressing a key that reopens the list -- same as a real user -- is // needed before the next selection. await userEvent.click(screen.getByText('videos/nature')); await userEvent.keyboard('{ArrowDown}'); await waitFor(() => { expect(screen.getByText('videos/tech')).toBeInTheDocument(); }); await userEvent.click(screen.getByText('videos/tech')); // Should call onSelect for each selection expect(mockOnSelect).toHaveBeenCalledWith('videos/nature'); expect(mockOnSelect).toHaveBeenCalledWith('videos/tech'); }); }); describe('Hook Integration', () => { it('calls setDirectoryName from hook on search change', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.type(input, 'archive'); expect(mockSetDirectoryName).toHaveBeenCalled(); }); it('displays results from hook state', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); await userEvent.click(input); await waitFor(() => { // Should display results from mock hook state expect(screen.getByText('videos/nature')).toBeInTheDocument(); expect(screen.getByText('videos/tech')).toBeInTheDocument(); expect(screen.getByText('News Channel')).toBeInTheDocument(); }); }); }); describe('Edge Cases', () => { it('renders without error when directories are null (initial state)', async () => { // Simulate the initial state before API call completes setMockHookState({ directories: null, channelDirectories: null, domainDirectories: null, isDir: false, directoryName: '', loading: true, }); // This should not throw an error render(); const input = screen.getByPlaceholderText(/search directory names/i); // Clicking input should not throw "Cannot read properties of null" await userEvent.click(input); // Component should still be rendered expect(input).toBeInTheDocument(); }); it('handles null/undefined initial value', () => { setMockHookState({ ...mockSearchResults, directoryName: '', }); render(); const input = screen.getByPlaceholderText(/search directory names/i); expect(input).toHaveValue(''); }); it('clears results display when empty', async () => { setMockHookState({ directories: [], channelDirectories: [], domainDirectories: [], isDir: false, directoryName: '', }); render(); const input = screen.getByPlaceholderText(/search directory names/i); expect(input).toHaveValue(''); }); it('maintains value when component remounts', () => { setMockHookState({ ...mockSearchResults, directoryName: 'videos/test', }); const {rerender} = render( ); expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument(); // Remount with same value rerender(); expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument(); }); it('handles special characters in path', async () => { render(); const input = screen.getByPlaceholderText(/search directory names/i); // Type path with special characters const specialPath = 'videos/test-folder_2024/v1.0'; await userEvent.type(input, specialPath); expect(mockSetDirectoryName).toHaveBeenCalled(); }); }); }); describe('SearchResultsInput', () => { const results = { tags: {name: 'Tags', results: [{type: 'tag', title: 'Cooking'}]}, videos: {name: 'Videos', results: [ {title: 'How To Sharpen An Axe', description: 'Wranglerstar', location: '/videos/1'}, ]}, }; it('hands the selected suggestion to its caller as {result}', async () => { /* * Regression: the migration passed the bare result, but every caller destructures * `{result}` (see Search.js), so `result.location` was undefined and clicking a * search suggestion navigated nowhere. */ const handleResultSelect = jest.fn(); render(); await userEvent.click(screen.getByRole('combobox')); await userEvent.click(screen.getByText('How To Sharpen An Axe')); expect(handleResultSelect).toHaveBeenCalledWith( {result: expect.objectContaining({location: '/videos/1'})}); }); it('forwards resultRenderer so a caller keeps its own suggestion markup', async () => { // Search.js renders a tag suggestion as a tag chip, not a line of text. const resultRenderer = (result) => rendered:{result.title}; render(); await userEvent.click(screen.getByRole('combobox')); expect(screen.getByText('rendered:Cooking')).toBeInTheDocument(); }); }); describe('contrastingColor', () => { /* * This decides the text color on every tag, against a fill the user picked, so it is the * one place in the app where legibility is computed rather than designed. */ it('picks whichever of the two options actually reads better', () => { // The palette blue. The old threshold check chose light text here, at 2.9:1, when dark // text on the same fill gives 5.3:1 -- the worse of the only two available answers. // Every mid-tone blue, teal and purple a user might choose sat in that band. const blue = '#2185d0'; const chosen = contrastingColor(blue); const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK; expect(contrastRatio(chosen, blue)).toBeGreaterThan(contrastRatio(rejected, blue)); expect(chosen).toBe(TAG_TEXT_DARK); }); it('never picks the worse option, for any color', () => { // The property, rather than a list of examples: whatever it returns must be at least as // legible as the alternative would have been. const colors = [ '#000000', '#ffffff', '#f2f2f2', '#1b1c1d', '#2185d0', '#21ba45', '#db2828', '#fbbd08', '#6435c9', '#a5673f', '#00b5ad', '#e03997', '#b5cc18', '#767676', '#808080', '#7f7f7f', ]; for (const color of colors) { const chosen = contrastingColor(color); const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK; expect(contrastRatio(chosen, color)) .toBeGreaterThanOrEqual(contrastRatio(rejected, color)); } }); it('returns one of the two text colors and nothing else', () => { // It is written into `--label-text`; anything undefined leaves the tag unstyled. for (const color of ['#000000', '#ffffff', '#2185d0']) { expect([TAG_TEXT_DARK, TAG_TEXT_LIGHT]).toContain(contrastingColor(color)); } }); it('measures contrast on the WCAG scale', () => { // Anchors the ratio itself, so "better" above is comparing real numbers. expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 1); expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5); }); }); describe('contrastingColor with shorthand hex', () => { /* * Tag colors reach the UI from the database, and a tags config is a file a user can edit * by hand -- configs are the source of truth in WROLPi -- so a perfectly valid `#fff` can * arrive here. `hexToRGBArray` used to reject anything but six digits, which left the * luminance at zero: a near-white tag was treated as black and given light text. */ it('reads a three-digit hex as the color it is', () => { expect(contrastRatio('#000000', '#fff')).toBeCloseTo(21, 1); expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(contrastRatio('#000000', '#fff'), 5); }); it('picks dark text for a near-white shorthand fill', () => { expect(contrastingColor('#fff')).toBe(TAG_TEXT_DARK); }); it('picks light text for a near-black shorthand fill', () => { expect(contrastingColor('#000')).toBe(TAG_TEXT_LIGHT); }); it('agrees with the six-digit form of the same color', () => { for (const [short, long] of [['#fff', '#ffffff'], ['#000', '#000000'], ['#f00', '#ff0000'], ['#1b2', '#11bb22']]) { expect(contrastingColor(short)).toBe(contrastingColor(long)); } }); }); describe('loadRole', () => { /* * The branches, tested as branches. This used to be a source guard scraping the * function body out of Common.js, because the obvious render test cannot work: jsdom * REJECTS `color: var(--danger)` as an invalid declaration and drops it, so every inline * color reads back as the empty string and the assertions passed for that reason alone. * Pulling the mapping out as a pure function is the honest fix. * * Thresholds are halves and three quarters of the core count, so each is asserted either * side of its boundary rather than at one comfortable value. */ it('says nothing about a load below half the cores', () => { expect(loadRole(1.9, 4)).toBeUndefined(); expect(loadRole(0, 4)).toBeUndefined(); }); it('warns from half the cores', () => { expect(loadRole(2.0, 4)).toBe('warning'); expect(loadRole(2.9, 4)).toBe('warning'); }); it('calls it danger from three quarters', () => { expect(loadRole(3.0, 4)).toBe('danger'); expect(loadRole(99, 4)).toBe('danger'); }); it('says nothing when the core count is unknown', () => { // Both thresholds are guarded on `cores`. Without it every load would read as fine, // which is the safe direction, but worth pinning so it stays deliberate. expect(loadRole(12, undefined)).toBeUndefined(); expect(loadRole(12, 0)).toBeUndefined(); }); it('never returns a hue', () => { // The regression this exists for: `orange` is byte-identical to `--text` in night, // so a warning load rendered as an ordinary uncolored number. [loadRole(1, 4), loadRole(2.5, 4), loadRole(4, 4)].filter(Boolean).forEach(role => expect(role).not.toMatch(/^(red|green|amber|yellow|orange|blue)$/)); }); }); describe('healthRole', () => { it('maps every SMART assessment to a severity', () => { expect(healthRole('PASS')).toBe('success'); expect(healthRole('WARN')).toBe('warning'); expect(healthRole('FAIL')).toBe('danger'); }); it('is not case sensitive, because the value comes from smartctl', () => { expect(healthRole('pass')).toBe('success'); expect(healthRole('Fail')).toBe('danger'); }); it('treats a missing or unrecognised assessment as unknown, not as healthy', () => { // Reporting an unreadable drive as PASS would be the dangerous direction. [undefined, null, '', 'SOMETHING_ELSE'].forEach(value => expect(healthRole(value)).toBe('neutral')); }); }); describe('a heading keeps its help icon on the same row as the text', () => { /* * `HelpHeader` and `InfoHeader` put their icon in a sibling `` beside a `