wrolpi/app/src/components/collections/CollectionEditForm.test.js
Roland 851485ef1b ui: Common.js and thirteen more files off Semantic
Phase 2, part 3.  Baseline 63 -> 50.

Common.js is the one that mattered: 51 files import it, so its Semantic usage
was propping up the rest of the migration.  Everything in it now comes from the
library -- Paginator, the two search inputs, DirectorySearch, TabLinks, the
toggles, the messages, APIButton, Breadcrumbs, SimpleAccordion, IframeViewer,
CardPoster, FileIcon, LoadStatistic, the theme menu, and the hotspot icons.

Also migrated: Placeholder, Apps, ArchivePreview, CbzViewer, AddToPlaylist,
TaggedDeleteConfirmModal, the collection edit/tag forms, and four inventory
components.

New in the library, all in the gallery and tested:

  SearchBox     Semantic's `<Search category>` with its grouped results shape
                kept, because four call sites already build it.  A real
                combobox rather than a div that listens for clicks.
  Pagination    Semantic's prop names; onPageChange gets the page number.
  TabBar        the strip only -- routing stays with the caller.
  IconStack     Semantic's IconGroup, named once for assistive technology.
  Table.HeaderCell sorted/onSort -- a real button carrying aria-sort.
  Button/IconButton translate Semantic's size names.

That last one is defensive: unmigrated callers still pass size='tiny', and
Mantine drops a size it does not recognise without a word, so the button
quietly renders at the wrong size.  Mapping here keeps the mixed state honest.

Three real bugs found and fixed on the way, none of them cosmetic:

  * `fileSuffixIconName` returned fourteen icon names the map did not have, so
    opening a folder with a PDF, a Word document or an archive in it logged an
    error and drew a fallback glyph.  All fourteen mapped, with a test that
    walks the mapper's own return values, and another asserting every mapped
    name resolves to a Tabler component that exists.
  * `Statistic` relied on Semantic's `Statistic.Group` for its spacing, so on
    the dashboard the load averages ran together into "1 MIN. LOAD5 MIN.
    LOAD15 MIN. LOAD".  It carries its own spacing now.
  * Semantic's inverted stylesheet colors every descendant of an inverted
    segment, so Toggle labels inherited #dddddd -- white pixels in night mode,
    on the Settings page, which is exactly where someone adjusts their WROLPi
    in the dark.  Restated from tokens.

The baseline ratchet gained a third assertion: a migrated file may still lean
on Semantic's CSS classes without importing the library, which the import check
cannot see and which would collapse silently when Phase 4 deletes the
stylesheet.  There is one such bridge today and it is now declared.
2026-07-30 11:55:39 -06:00

270 lines
8.8 KiB
JavaScript

import React from 'react';
import {
createMockDomain,
createTestForm,
render,
renderInDarkMode,
renderInLightMode,
screen
} from '../../test-utils';
import {CollectionEditForm} from './CollectionEditForm';
// Mock the TagsContext
jest.mock('../../Tags', () => ({
TagsContext: {
_currentValue: {
SingleTag: ({name}) => <span data-testid="applied-tag">{name}</span>
}
},
}));
// Mock Common components
jest.mock('../Common', () => ({
...jest.requireActual('../Common'),
WROLModeMessage: ({content}) => <div data-testid="wrol-mode-message">{content}</div>,
}));
describe('CollectionEditForm', () => {
const mockCollection = createMockDomain();
describe('Form Rendering', () => {
it('renders children and Save button', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form}>
<div data-testid="child-content">Form fields here</div>
</CollectionEditForm>
);
expect(screen.getByTestId('child-content')).toBeInTheDocument();
expect(screen.getByRole('button', {name: /save/i})).toBeInTheDocument();
});
it('renders title when provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form} title="Edit Domain: example.com">
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByRole('heading', {level: 1})).toHaveTextContent('Edit Domain: example.com');
});
it('renders WROL mode message when provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
wrolModeContent="Editing disabled in WROL Mode"
>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByTestId('wrol-mode-message')).toHaveTextContent('Editing disabled in WROL Mode');
});
it('renders action buttons when provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
actionButtons={<button data-testid="delete-button">Delete</button>}
>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
it('renders appliedTagName when provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form} appliedTagName="News">
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByTestId('applied-tag')).toHaveTextContent('News');
});
it('does not render appliedTagName when not provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.queryByTestId('applied-tag')).not.toBeInTheDocument();
});
});
describe('Save/Cancel Actions', () => {
it('shows Cancel button when onCancel provided', () => {
const form = createTestForm(mockCollection);
const mockOnCancel = jest.fn();
render(
<CollectionEditForm form={form} onCancel={mockOnCancel}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByRole('button', {name: /cancel/i})).toBeInTheDocument();
});
it('does not show Cancel button when onCancel not provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.queryByRole('button', {name: /cancel/i})).not.toBeInTheDocument();
});
it('disables Save button when form is disabled', () => {
const form = createTestForm(mockCollection, {
overrides: {disabled: true}
});
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByRole('button', {name: /save/i})).toBeDisabled();
});
it('disables Cancel button when form is disabled', () => {
const form = createTestForm(mockCollection, {
overrides: {disabled: true}
});
const mockOnCancel = jest.fn();
render(
<CollectionEditForm form={form} onCancel={mockOnCancel}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByRole('button', {name: /cancel/i})).toBeDisabled();
});
});
describe('Error States', () => {
it('displays form-level errors', () => {
const form = createTestForm(mockCollection, {
overrides: {error: 'Something went wrong'}
});
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
it('does not display error message when no error', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('calls form.onSubmit when Save button is clicked', () => {
const form = createTestForm(mockCollection);
form.onSubmit = jest.fn();
render(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
screen.getByRole('button', {name: /save/i}).click();
expect(form.onSubmit).toHaveBeenCalled();
});
});
describe('Theme Integration', () => {
// Panel/Header are token-driven (see ui/Surfaces.tsx): their colors come from CSS
// variables scoped on `[data-theme]`, not from JS branching on the theme. There is
// no more "inverted" prop/class to assert on -- just that the same markup renders
// in both modes.
it('renders a token-driven Panel in dark mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument();
});
it('renders a token-driven Panel in light mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInLightMode(
<CollectionEditForm form={form}>
<div>Form content</div>
</CollectionEditForm>
);
expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument();
});
it('renders a token-driven Header for the title', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
<CollectionEditForm form={form} title="Test Collection">
<div>Form content</div>
</CollectionEditForm>
);
const header = container.querySelector('.wrolpi-header');
expect(header).toBeInTheDocument();
expect(header).toHaveTextContent('Test Collection');
});
});
describe('CSS Classes', () => {
it('uses action-button-spacing class for cancel button', () => {
const form = createTestForm(mockCollection);
const mockOnCancel = jest.fn();
render(
<CollectionEditForm form={form} onCancel={mockOnCancel}>
<div>Form content</div>
</CollectionEditForm>
);
const cancelButton = screen.getByRole('button', {name: /cancel/i});
expect(cancelButton).toHaveClass('action-button-spacing');
});
});
});