2025-10-29 22:16:33 -06:00
|
|
|
import React from 'react';
|
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 10:00:48 -06:00
|
|
|
import {act, render, screen, waitFor} from '../test-utils';
|
2025-10-29 22:16:33 -06:00
|
|
|
import userEvent from '@testing-library/user-event';
|
ui: tags look like tags, and stay readable in every theme
Four things, three cosmetic and one a real legibility bug.
* Tags were square chips. They now have a pointed left edge and an
eyelet, like a tag tied to a garment -- the shape Semantic gave them,
and the reason a tag reads as a tag rather than as a small button. The
point is a square rotated behind the left edge, sized so its diagonal
equals the body height and its two edges meet the body at the corners.
Only where something is actually a tag. `Label` carries the count
badges in the Tags table too -- file, zim, channel and domain counts --
and a point on a number reads as an arrow, so the shape is behind a
`tag` prop and `NameToTagLabel` opts in.
* They were smaller than what they replaced: 11.5px text with 3px of
padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which
also keeps them a reasonable thing to tap.
* Wrapped rows of tags sat on the row above, so they carry a bottom
margin, plus a left margin for the point to occupy.
* The text colour was unreadable in night and wrong in amber.
Tags.js calculates black-or-light against the colour the user chose for
each tag, and wrote the answer to `color` inline -- a declaration no
stylesheet rule can outrank. Night turns a tag into an outline over a
near-black page, so a near-white tag kept the black text calculated for
its fill and was left legible only by its border: 1.1:1, measured. Amber
replaces every fill with its own bright orange and was painting `#dddddd`
on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole
point is that no other hue reaches the eye.
The fix is to stop hard-coding `color` so the theme can decide: the
calculated colour goes into `--label-text`, and night and amber override
it alongside the fill they already override. There is no second contrast
function to write, which is what this looked like it needed -- once a
theme has discarded the user's colour there is nothing left to calculate
against, and the answer is just the theme's own token.
`.wrolpi-label-white` and `-black` moved to the same variable. Setting
`color` there outranked the base rule, so amber was painting `--white` on
`--text`: light orange on orange.
`contrastingColor` was also picking the worse of its two options. It
compared a brightness figure against a threshold, and the figure weighted
the gamma-encoded bytes without linearising them, so Semantic's blue
`#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every
mid-tone blue, teal and purple a user might pick sat in that band. It
now measures the WCAG contrast ratio of both candidates and returns the
better one, which needs no threshold at all.
Tests
-----
`Tags.test.js` is new, and covers the chip nothing covered: every other
spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as
a function returning the name. That is how a tag could be unreadable
without a single test noticing. Two of these were verified red against
the inline `color`.
`cy.contrastRatio()` measures text against the surface *actually* behind
it, walking up to the first ancestor that paints something -- a
transparent tag has no background of its own, so a naive check reads
rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three
tag colours across four themes, at WCAG AA. Plus a test asserting the
old inline-`color` version still measures below 1.5:1, so the check is
provably able to fail.
The gallery had label swatches in theme colours and no user-coloured tag,
which is why none of this was visible in it. It has eleven now, spanning
the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
|
|
|
import {
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
contrastingColor, contrastRatio, DirectorySearch, HelpHeader, InfoHeader, loadRole,
|
|
|
|
|
SearchResultsInput, TAG_TEXT_DARK, TAG_TEXT_LIGHT,
|
ui: tags look like tags, and stay readable in every theme
Four things, three cosmetic and one a real legibility bug.
* Tags were square chips. They now have a pointed left edge and an
eyelet, like a tag tied to a garment -- the shape Semantic gave them,
and the reason a tag reads as a tag rather than as a small button. The
point is a square rotated behind the left edge, sized so its diagonal
equals the body height and its two edges meet the body at the corners.
Only where something is actually a tag. `Label` carries the count
badges in the Tags table too -- file, zim, channel and domain counts --
and a point on a number reads as an arrow, so the shape is behind a
`tag` prop and `NameToTagLabel` opts in.
* They were smaller than what they replaced: 11.5px text with 3px of
padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which
also keeps them a reasonable thing to tap.
* Wrapped rows of tags sat on the row above, so they carry a bottom
margin, plus a left margin for the point to occupy.
* The text colour was unreadable in night and wrong in amber.
Tags.js calculates black-or-light against the colour the user chose for
each tag, and wrote the answer to `color` inline -- a declaration no
stylesheet rule can outrank. Night turns a tag into an outline over a
near-black page, so a near-white tag kept the black text calculated for
its fill and was left legible only by its border: 1.1:1, measured. Amber
replaces every fill with its own bright orange and was painting `#dddddd`
on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole
point is that no other hue reaches the eye.
The fix is to stop hard-coding `color` so the theme can decide: the
calculated colour goes into `--label-text`, and night and amber override
it alongside the fill they already override. There is no second contrast
function to write, which is what this looked like it needed -- once a
theme has discarded the user's colour there is nothing left to calculate
against, and the answer is just the theme's own token.
`.wrolpi-label-white` and `-black` moved to the same variable. Setting
`color` there outranked the base rule, so amber was painting `--white` on
`--text`: light orange on orange.
`contrastingColor` was also picking the worse of its two options. It
compared a brightness figure against a threshold, and the figure weighted
the gamma-encoded bytes without linearising them, so Semantic's blue
`#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every
mid-tone blue, teal and purple a user might pick sat in that band. It
now measures the WCAG contrast ratio of both candidates and returns the
better one, which needs no threshold at all.
Tests
-----
`Tags.test.js` is new, and covers the chip nothing covered: every other
spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as
a function returning the name. That is how a tag could be unreadable
without a single test noticing. Two of these were verified red against
the inline `color`.
`cy.contrastRatio()` measures text against the surface *actually* behind
it, walking up to the first ancestor that paints something -- a
transparent tag has no background of its own, so a naive check reads
rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three
tag colours across four themes, at WCAG AA. Plus a test asserting the
old inline-`color` version still measures below 1.5:1, so the check is
provably able to fail.
The gallery had label swatches in theme colours and no user-coloured tag,
which is why none of this was visible in it. It has eleven now, spanning
the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
|
|
|
} from './Common';
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
import {healthRole} from './admin/ControllerPage';
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
// 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', () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
expect(input).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('shows initial value when provided', () => {
|
|
|
|
|
setMockHookState({
|
|
|
|
|
...mockSearchResults,
|
|
|
|
|
directoryName: 'videos/test',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByDisplayValue('videos/test');
|
|
|
|
|
expect(input).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies disabled state correctly', () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
expect(input).toBeDisabled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-24 15:23:32 -06:00
|
|
|
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, Semantic UI's Search crashes reading null.length when the menu opens.
|
|
|
|
|
setMockHookState({
|
|
|
|
|
directories: [],
|
|
|
|
|
channelDirectories: [],
|
|
|
|
|
domainDirectories: [],
|
|
|
|
|
isDir: false,
|
|
|
|
|
directoryName: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value={null}/>);
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
it('displays with required indicator', () => {
|
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 10:00:48 -06:00
|
|
|
render(
|
2025-12-06 14:49:46 -07:00
|
|
|
<DirectorySearch onSelect={mockOnSelect} value="" required/>
|
2025-10-29 22:16:33 -06:00
|
|
|
);
|
|
|
|
|
|
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 10:00:48 -06:00
|
|
|
// Unlike Semantic UI's Search, the SearchBox forwards `required` straight
|
|
|
|
|
// onto the input.
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
expect(input).toBeRequired();
|
2025-10-29 22:16:33 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Search Functionality', () => {
|
|
|
|
|
it('triggers setDirectoryName on value change', async () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
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 10:00:48 -06:00
|
|
|
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
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 10:00:48 -06:00
|
|
|
// SearchBox renders a Loader alongside the input while `loading` is true.
|
|
|
|
|
expect(container.querySelector('.wrolpi-searchbox-loading')).toBeInTheDocument();
|
2025-10-29 22:16:33 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('hides loading indicator when loading state is false', () => {
|
|
|
|
|
setMockHookState({
|
|
|
|
|
...mockSearchResults,
|
|
|
|
|
loading: false,
|
|
|
|
|
});
|
|
|
|
|
|
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 10:00:48 -06:00
|
|
|
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
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 10:00:48 -06:00
|
|
|
expect(container.querySelector('.wrolpi-searchbox-loading')).not.toBeInTheDocument();
|
2025-10-29 22:16:33 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('displays categorized results', async () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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 () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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 () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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)
|
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 10:00:48 -06:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
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 10:00:48 -06:00
|
|
|
// 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);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
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 10:00:48 -06:00
|
|
|
// Blur the input - should trigger onBlur which calls onSelect
|
2025-10-29 22:16:33 -06:00
|
|
|
await act(async () => {
|
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 10:00:48 -06:00
|
|
|
const {fireEvent} = require('../test-utils');
|
|
|
|
|
fireEvent.blur(input);
|
2025-10-29 22:16:33 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value="existing/path"/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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', () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
|
|
|
|
|
// Input should be disabled
|
|
|
|
|
expect(input).toBeDisabled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles rapid selection changes', async () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
await userEvent.click(input);
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('videos/nature')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
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 10:00:48 -06:00
|
|
|
// 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.
|
2025-10-29 22:16:33 -06:00
|
|
|
await userEvent.click(screen.getByText('videos/nature'));
|
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 10:00:48 -06:00
|
|
|
await userEvent.keyboard('{ArrowDown}');
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByText('videos/tech')).toBeInTheDocument();
|
|
|
|
|
});
|
2025-10-29 22:16:33 -06:00
|
|
|
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 () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
const input = screen.getByPlaceholderText(/search directory names/i);
|
|
|
|
|
await userEvent.type(input, 'archive');
|
|
|
|
|
|
|
|
|
|
expect(mockSetDirectoryName).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('displays results from hook state', async () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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', () => {
|
2025-12-05 18:26:36 -07:00
|
|
|
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
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-12-05 18:26:36 -07:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-29 22:16:33 -06:00
|
|
|
it('handles null/undefined initial value', () => {
|
|
|
|
|
setMockHookState({
|
|
|
|
|
...mockSearchResults,
|
|
|
|
|
directoryName: '',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value={null}/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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: '',
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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(
|
2025-12-06 14:49:46 -07:00
|
|
|
<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>
|
2025-10-29 22:16:33 -06:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
// Remount with same value
|
2025-12-06 14:49:46 -07:00
|
|
|
rerender(<DirectorySearch onSelect={mockOnSelect} value="videos/test"/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles special characters in path', async () => {
|
2025-12-06 14:49:46 -07:00
|
|
|
render(<DirectorySearch onSelect={mockOnSelect} value=""/>);
|
2025-10-29 22:16:33 -06:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
ui: fix search suggestion selection, rendering, and clipping
Three findings from review and CI on the Common.js migration, all real.
Selecting a suggestion navigated nowhere. Semantic handed its callers
`{result}` and every caller destructures that shape, so passing the bare result
left `result.location` undefined -- the search suggestions and the Ctrl+K modal
both stopped navigating. The shape stays until the call sites can change
together. (Greptile P1.)
Suggestions lost their custom markup. Callers pass `resultRenderer` to draw a
tag as a chip rather than a line of text, and the migrated wrapper forwarded it
into a div's props instead of to SearchBox, so every suggestion fell back to the
generic title and description. SearchBox takes it now. (Greptile P2.)
The suggestion list clipped its last group. A cypress spec caught it: with
directories, channels and domains all present, the content runs past the 320px
cap and the final row was cut off. Eight grouped suggestions is an ordinary
result set, so the cap was simply too low; it is viewport-relative now and
still bounded.
Both regressions are pinned by tests -- one asserting the callback shape a
caller actually destructures, one asserting the custom renderer replaces the
default markup rather than wrapping it.
2026-07-30 11:28:17 -06:00
|
|
|
|
|
|
|
|
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(<SearchResultsInput searchStr='axe' onSubmit={jest.fn()} results={results}
|
|
|
|
|
handleResultSelect={handleResultSelect}/>);
|
|
|
|
|
|
|
|
|
|
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) => <span>rendered:{result.title}</span>;
|
|
|
|
|
render(<SearchResultsInput searchStr='cook' onSubmit={jest.fn()} results={results}
|
|
|
|
|
handleResultSelect={jest.fn()} resultRenderer={resultRenderer}/>);
|
|
|
|
|
|
|
|
|
|
await userEvent.click(screen.getByRole('combobox'));
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('rendered:Cooking')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
ui: tags look like tags, and stay readable in every theme
Four things, three cosmetic and one a real legibility bug.
* Tags were square chips. They now have a pointed left edge and an
eyelet, like a tag tied to a garment -- the shape Semantic gave them,
and the reason a tag reads as a tag rather than as a small button. The
point is a square rotated behind the left edge, sized so its diagonal
equals the body height and its two edges meet the body at the corners.
Only where something is actually a tag. `Label` carries the count
badges in the Tags table too -- file, zim, channel and domain counts --
and a point on a number reads as an arrow, so the shape is behind a
`tag` prop and `NameToTagLabel` opts in.
* They were smaller than what they replaced: 11.5px text with 3px of
padding, against Semantic's 12px and ~7px. Now 12.5px and 5px, which
also keeps them a reasonable thing to tap.
* Wrapped rows of tags sat on the row above, so they carry a bottom
margin, plus a left margin for the point to occupy.
* The text colour was unreadable in night and wrong in amber.
Tags.js calculates black-or-light against the colour the user chose for
each tag, and wrote the answer to `color` inline -- a declaration no
stylesheet rule can outrank. Night turns a tag into an outline over a
near-black page, so a near-white tag kept the black text calculated for
its fill and was left legible only by its border: 1.1:1, measured. Amber
replaces every fill with its own bright orange and was painting `#dddddd`
on `#ff9500`, about 1.9:1, and a neutral grey in a theme whose whole
point is that no other hue reaches the eye.
The fix is to stop hard-coding `color` so the theme can decide: the
calculated colour goes into `--label-text`, and night and amber override
it alongside the fill they already override. There is no second contrast
function to write, which is what this looked like it needed -- once a
theme has discarded the user's colour there is nothing left to calculate
against, and the answer is just the theme's own token.
`.wrolpi-label-white` and `-black` moved to the same variable. Setting
`color` there outranked the base rule, so amber was painting `--white` on
`--text`: light orange on orange.
`contrastingColor` was also picking the worse of its two options. It
compared a brightness figure against a threshold, and the figure weighted
the gamma-encoded bytes without linearising them, so Semantic's blue
`#2185d0` got light text at 2.9:1 where dark text gives 5.3:1. Every
mid-tone blue, teal and purple a user might pick sat in that band. It
now measures the WCAG contrast ratio of both candidates and returns the
better one, which needs no threshold at all.
Tests
-----
`Tags.test.js` is new, and covers the chip nothing covered: every other
spec takes `NameToTagLabel` from `tagsContextFixture`, which stubs it as
a function returning the name. That is how a tag could be unreadable
without a single test noticing. Two of these were verified red against
the inline `color`.
`cy.contrastRatio()` measures text against the surface *actually* behind
it, walking up to the first ancestor that paints something -- a
transparent tag has no background of its own, so a naive check reads
rgba(0,0,0,0) and concludes anything is legible. Twelve cases, three
tag colours across four themes, at WCAG AA. Plus a test asserting the
old inline-`color` version still measures below 1.5:1, so the check is
provably able to fail.
The gallery had label swatches in theme colours and no user-coloured tag,
which is why none of this was visible in it. It has eleven now, spanning
the range where the black-or-light decision flips.
2026-07-30 19:46:10 -06:00
|
|
|
|
|
|
|
|
describe('contrastingColor', () => {
|
|
|
|
|
/*
|
|
|
|
|
* This decides the text colour 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', () => {
|
|
|
|
|
// Semantic's 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 colour', () => {
|
|
|
|
|
// The property, rather than a list of examples: whatever it returns must be at least as
|
|
|
|
|
// legible as the alternative would have been.
|
|
|
|
|
const colours = [
|
|
|
|
|
'#000000', '#ffffff', '#f2f2f2', '#1b1c1d', '#2185d0', '#21ba45', '#db2828',
|
|
|
|
|
'#fbbd08', '#6435c9', '#a5673f', '#00b5ad', '#e03997', '#b5cc18', '#767676',
|
|
|
|
|
'#808080', '#7f7f7f',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const colour of colours) {
|
|
|
|
|
const chosen = contrastingColor(colour);
|
|
|
|
|
const rejected = chosen === TAG_TEXT_DARK ? TAG_TEXT_LIGHT : TAG_TEXT_DARK;
|
|
|
|
|
expect(contrastRatio(chosen, colour))
|
|
|
|
|
.toBeGreaterThanOrEqual(contrastRatio(rejected, colour));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns one of the two text colours and nothing else', () => {
|
|
|
|
|
// It is written into `--label-text`; anything undefined leaves the tag unstyled.
|
|
|
|
|
for (const colour of ['#000000', '#ffffff', '#2185d0']) {
|
|
|
|
|
expect([TAG_TEXT_DARK, TAG_TEXT_LIGHT]).toContain(contrastingColor(colour));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-01 14:34:17 -06:00
|
|
|
|
|
|
|
|
describe('contrastingColor with shorthand hex', () => {
|
|
|
|
|
/*
|
|
|
|
|
* Tag colours 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 colour 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 colour', () => {
|
|
|
|
|
for (const [short, long] of [['#fff', '#ffffff'], ['#000', '#000000'],
|
|
|
|
|
['#f00', '#ff0000'], ['#1b2', '#11bb22']]) {
|
|
|
|
|
expect(contrastingColor(short)).toBe(contrastingColor(long));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
describe('loadRole', () => {
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
/*
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
* 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
|
|
|
|
|
* colour 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.
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
*
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
* 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.
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
*/
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
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 uncoloured 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)$/));
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
test: act on review — the browser test named LoadStatistic but never mounted it
The suite was titled "a load reading ranks itself", commented as covering the
thresholds, and cross-referenced from Common.test.js as the place the painted
result is checked. It mounted a hand-built `<Statistic color='warning'/>`.
So it proved that role tokens resolve distinctly -- which the roles suite two
blocks up already covers -- and would have stayed green with LoadStatistic
reverted to `orange`/`red`. The same shape as the column-border test in #609:
a claim that outran its measurement. It mounts the real helper at the real
thresholds now, and the reversion fails it.
The source guards are gone. They scraped function bodies out of files with
indexOf slices, covered two helpers, and never executed a branch. `loadRole`
and `healthRole` are pure exported functions instead, tested as mappings:
either side of both load thresholds, every SMART assessment, lower case (the
value comes from smartctl), and the case that matters most -- an unreadable
drive must report as unknown, never as healthy.
That is also the better answer to the jsdom problem that produced the guards.
jsdom rejects `color: var(--danger)` and drops it, so the mapping cannot be read
back off a rendered component; extracting it is the honest fix rather than
asserting on source text.
And the gallery was itself half-migrated: 15 Min. Load became `danger` while
5 Min. Load stayed `orange` -- in the one sample whose caption tells the reader
to go and check night and amber, where orange is byte-identical to --text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:17:18 -06:00
|
|
|
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'));
|
ui: route genuine success and failure through the roles
Sixteen call sites, chosen by reading each one rather than by grepping for a
hue. Four sub-agents judged them independently against one rule: convert only
what genuinely means success or failure; identification, category and
decoration stay hue-named.
Converted:
SMART drive health (all four branches -- PASS/WARN/FAIL/unknown)
config validity: valid / invalid / empty
"Failed to load preview"
the two "Download error" triggers
expired inventory items, desktop and mobile
the refresh phases: complete / active / not reached
a refresh Progress bar in its error state
cookie status: Unlocked / Locked
a chip-mismatch label on the flasher
CPU load thresholds (LoadStatistic)
the outcome of an API call in useAPIButton
the gallery's "Danger zone" heading and its load readings
Left alone, deliberately:
the file browser toolbar -- every action has its own colour to be told apart,
and delete already uses role='danger' (Roland's ruling)
diff add/remove in the config and inventory import previews. Adding is not a
success and removing is not a failure; in Overwrite mode the removals are
exactly what the user asked for, and painting them danger would report their
intended action as broken
seasons in DatesSelector, the uploader star and favourite heart, TagIcon,
downloader category buttons, "Recommended to Keep"
Three sites were wrong in a way worth naming. Both "Download error" triggers
guard on the same condition and render the same modal, but one was `orange` and
the other `red` -- and `orange` is the single worst choice, byte-identical to
`--text` in night, so that error button was indistinguishable from an ordinary
control. Config validity drew its valid state in `--violet`, which no user
parses as "valid". And `LoadStatistic` -- the thing users actually watch on the
Status page -- rendered its warning threshold in that same invisible orange.
Also fixed a stale comment in Videos.js that claimed no token existed for these.
Two notes on the tests:
The first LoadStatistic test I wrote rendered the component and asserted the
inline colour. jsdom REJECTS `color: var(--danger)` as an invalid declaration
and drops it, so every colour reads back as the empty string -- two of the four
assertions passed for that reason alone and would have kept passing with the
hues restored. It is a source guard now, with the painted result asserted in
the browser instead.
That browser test then found something real: an uncoloured reading inherits
`--text`, which in night is 4.98:1 against `--warning`'s 4.91:1, so a machine
under load is marginally QUIETER than one that is fine. Not fixed here. The
only way up on a dark panel is lightness, and night's danger would desaturate
from #ff3e3e to about #ff5d5d -- trading the theme's "red only" character for
loudness. That is a palette decision, so the test documents it and asserts what
does hold rather than quietly making it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:08:34 -06:00
|
|
|
});
|
|
|
|
|
});
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
|
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.
The control is now a SIBLING of the heading inside a flex row, not a child of
it. Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help". The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.
`style` now lands on the wrapper. It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border. Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2. Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.
A `<label>` only when `for_` names a field. Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous. Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.
The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.
The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now. Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.
Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.
The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either. The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:00:32 -06:00
|
|
|
describe('a heading keeps its help icon on the same row as the text', () => {
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
/*
|
|
|
|
|
* `HelpHeader` and `InfoHeader` put their icon in a sibling `<span>` beside a `<label>`,
|
|
|
|
|
* held on one line by `.inline-header h1..h5 {display: inline-block}` in App.css. That
|
|
|
|
|
* worked while the heading was a bare `<h2>`; the moment it gained a block wrapper the
|
|
|
|
|
* label's block child forced a break, and every help icon in the app dropped below its
|
|
|
|
|
* own heading -- visible on "Root CA Certificate" in Settings.
|
|
|
|
|
*
|
|
|
|
|
* They pass the icon through the heading's `after` slot now. Containment is the claim
|
|
|
|
|
* jsdom can judge; that the two actually share a line is asserted in ui-layout.cy.js,
|
|
|
|
|
* since jsdom has no layout at all.
|
|
|
|
|
*/
|
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.
The control is now a SIBLING of the heading inside a flex row, not a child of
it. Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help". The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.
`style` now lands on the wrapper. It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border. Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2. Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.
A `<label>` only when `for_` names a field. Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous. Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.
The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.
The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now. Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.
Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.
The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either. The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:00:32 -06:00
|
|
|
it("renders the help control in the heading's row", () => {
|
|
|
|
|
const {container} = render(
|
|
|
|
|
<HelpHeader headerSize='h3' headerContent='Root CA Certificate'
|
|
|
|
|
helpPath='/system/certificates/'/>);
|
|
|
|
|
|
|
|
|
|
expect(container.querySelector('.wrolpi-header-row'))
|
|
|
|
|
.toContainElement(screen.getByRole('button', {name: 'Help'}));
|
|
|
|
|
// And not inside the heading itself, which would put it in the accessible name.
|
|
|
|
|
expect(screen.getByRole('heading')).toHaveAccessibleName('Root CA Certificate');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders the info popup in the heading's row", () => {
|
|
|
|
|
const {container} = render(
|
|
|
|
|
<InfoHeader headerSize='h4' headerContent='Video Resolutions'
|
|
|
|
|
popupContent='Highest available is tried first.'/>);
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
|
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.
The control is now a SIBLING of the heading inside a flex row, not a child of
it. Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help". The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.
`style` now lands on the wrapper. It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border. Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2. Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.
A `<label>` only when `for_` names a field. Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous. Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.
The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.
The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now. Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.
Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.
The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either. The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:00:32 -06:00
|
|
|
expect(container.querySelector('.wrolpi-header-after svg')).not.toBeNull();
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
});
|
|
|
|
|
|
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.
The control is now a SIBLING of the heading inside a flex row, not a child of
it. Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help". The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.
`style` now lands on the wrapper. It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border. Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2. Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.
A `<label>` only when `for_` names a field. Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous. Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.
The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.
The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now. Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.
Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.
The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either. The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:00:32 -06:00
|
|
|
it('renders no label at all when there is no field to name', () => {
|
|
|
|
|
/*
|
|
|
|
|
* Several call sites pass an InfoHeader as the `label` prop of InputForm/ToggleForm,
|
|
|
|
|
* which wraps it in a `<label>` of its own. An unconditional label here nested one
|
|
|
|
|
* inside another -- invalid HTML, and it makes clicking the text ambiguous.
|
|
|
|
|
*/
|
|
|
|
|
const {container} = render(
|
|
|
|
|
<InfoHeader headerSize='h5' headerContent='Video File Format' popupContent='...'/>);
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
|
test: act on review — and one of the seven turned out to be undemonstrable
Six real fixes and one honest retraction.
The control is now a SIBLING of the heading inside a flex row, not a child of
it. Inside the `<h3>` it joined the heading's accessible name, so screen reader
heading navigation announced "Root CA Certificate Help". The row is what keeps
them on one line, and it is only rendered when there is something to sit beside,
so the markup for the other 46 call sites is unchanged.
`style` now lands on the wrapper. It used to reach the outer div; going through
Header put it on the inner heading, where a margin sets space INSIDE the wrapper
and only worked at all through margin collapse -- which stops the moment the
wrapper gains padding or a border. Status's "Drive Bandwidth" is the live case:
verified 16px on the block, 0 on the h2. Text properties still arrive, because
colour and text-align inherit; the `color` PROP stays on the heading.
A `<label>` only when `for_` names a field. Several call sites pass an
InfoHeader as the `label` prop of InputForm/ToggleForm, which wraps it in a
label of its own -- an unconditional one nested labels, which is invalid and
makes clicking the text ambiguous. Verified: zero `label label` on
/videos/settings, where "Video Resolutions" keeps its label and "Video File
Format" no longer has one.
The help controls carried `iconStyle: {margin: '0.5em'}`, which separated a
free-floating sibling and, inside the row, added to the gap on every side.
Zeroed; the row's gap is the spacing.
The browser test mounted a plain Button in an `after` slot rather than a real
HelpHeader, so the default icon chrome above was exactly what it could not see.
It mounts the real wrapper now. Two of my assertions were also too weak to
catch what they claimed: the gap was measured to `.wrolpi-header-after`, a flex
container whose own rect does not move when a child gains a margin, so it had to
measure the button; and the squeeze case used a title of ordinary words, which
simply wraps and never puts the row under pressure.
Renamed the suites -- "keeps the icon on its own line" read as the opposite of
the fix.
The retraction: `flex-shrink: 0` is declared but has NO test. Three attempts
could not find a width where removing it changes what is painted -- the row's
only other item is a heading, and its automatic minimum size absorbs the
pressure; the help control is an ActionIcon with a min-width it will not go
below either. The declaration stays as a statement of intent, with a comment
saying it is unproven, rather than a green test that does not discriminate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:00:32 -06:00
|
|
|
expect(container.querySelector('label')).toBeNull();
|
|
|
|
|
expect(screen.getByRole('heading', {name: /Video File Format/})).toBeInTheDocument();
|
ui: headings open their section, and keep their help icon on one line
Two things, and the second was a regression this migration caused.
A heading had `margin: 0 0 10px` -- no top margin at all -- so it sat flush
against whatever came before and read as part of it rather than as the start of
the next thing. On /more/statistics "Tags" touched the panel above it with a
0px gap. Now 22px above and 10px below, because a heading belongs to what
follows it.
`* + .wrolpi-header` rather than a blanket margin, for the same reason
StatisticGroup needed it: a heading that opens a Panel or a page is already
where it should be, and pushing it down just dents the top of its own container.
The help icon on every HelpHeader and InfoHeader had dropped to its own line --
"Root CA Certificate" in Settings is the clearest one. `.inline-header` set
`h1..h5 {display: inline-block}` so the heading and a sibling `<span>` shared a
row, which worked while these were a bare `<h2>` and stopped the moment they
gained a block wrapper: the `<label>`'s block child forced a break.
Fixed in the library rather than patched in App.css. `Header` takes an `after`
slot rendered inside the heading element, where the existing flex row already
handles alignment and spacing. Both wrappers pass their icon through it, the
`<label>` moves inside the heading so `for_` keeps associating the text with its
field (three call sites rely on it), and the `.inline-header` rules are deleted.
Fifteen call sites are fixed by fixing the two wrappers.
Tests: containment in jest, and the part jsdom cannot judge -- that the two
boxes actually share a line, and that the heading stays one row tall -- in a
browser. Planted both original shapes: the margin removed, a blanket margin
denting its container, and the control moved back outside the heading element.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 18:41:58 -06:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the label association, which is what `for_` is for', () => {
|
|
|
|
|
// Three call sites use it to tie the heading text to the field below it.
|
|
|
|
|
render(<InfoHeader headerSize='h4' headerContent='Video Resolutions'
|
|
|
|
|
for_='video_resolutions_input' popupContent='...'/>);
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText(/Video Resolutions/).closest('label'))
|
|
|
|
|
.toHaveAttribute('for', 'video_resolutions_input');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves no .inline-header behind', () => {
|
|
|
|
|
// The class and its stylesheet rules are gone; a stray one would be dead markup
|
|
|
|
|
// relying on CSS that no longer exists.
|
|
|
|
|
const {container} = render(<HelpHeader headerContent='Root CA Certificate' helpPath='/x/'/>);
|
|
|
|
|
|
|
|
|
|
expect(container.querySelector('.inline-header')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|