wrolpi/app/src/hooks/customHooks.test.js
Roland 048fea07ca
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as
though it were there: identifiers named after it, rules written against its
class names, and about 180 comments explaining a rule in terms of a thing that
is no longer installed.

Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap ->
navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames ->
iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors ->
NavColors in wrolpi/schema.py, which is what validates nav_color on the way in.
ColorToSemanticHexColor had no callers at all and is gone.

The baseline ratchet has done its job -- the list reached zero, so the JSON is
deleted and the guard keeps only the checks with teeth: no package, no import,
no `className="ui "`, no toast library.  Its Theme.tsx check went with the
wrappers it was watching for.  It also asserts its own walk found files, so a
clean result cannot mean it scanned nothing.

Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or
`.modal`, none of which any component has set since the stylesheet stopped
loading, plus the form skeletons, ui-alerts and progress-text rules that
nothing renders.  Two of them were doing harm rather than nothing: the drag
tints had a dark-theme variant under `.inverted` that stopped applying, leaving
15% of a fixed blue over a dark row, and now mix a token instead.

Two selectors naming markup that no longer exists were live bugs.  The file
browser's filter had `.ui.input.file-browser-filter`, so it rendered at the
browser default width on a bar laid out to give it 34em -- now qualified by its
container, because `.wrolpi-action-input` sets `width: 100%` at the same
specificity and is loaded later.  And SearchBox takes named props rather than a
rest spread, so the `id` DestinationForm hands it was dropped in silence and
the label it renders pointed at nothing; the download forms' Destination field
has had no associated label since the migration.

"Semantic roles" survives in four places, where it means what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:15:57 -06:00

280 lines
11 KiB
JavaScript

import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {usePages, useDriveTemperature, useDriveHealth} from './customHooks';
import {QueryContext, StatusContext} from '../contexts/contexts';
import {Paginator} from '../components/Common';
// Build a wrapper that provides a controlled QueryContext so usePages can read
// `o` and `l` from searchParams without a real router.
const makeWrapper = (initialParams = {}) => {
const params = new URLSearchParams(initialParams);
const Wrapper = ({children}) => {
const [searchParams, setSearchParams] = React.useState(params);
const updateQuery = (newParams) => {
const next = new URLSearchParams(searchParams.toString());
Object.entries(newParams).forEach(([k, v]) => {
if (v === null || v === undefined) {
next.delete(k);
} else {
next.set(k, String(v));
}
});
setSearchParams(next);
};
return (
<QueryContext.Provider value={{searchParams, updateQuery, getLocationStr: () => '/'}}>
{children}
</QueryContext.Provider>
);
};
return Wrapper;
};
// Mock Media so Paginator renders both mobile + tablet variants synchronously.
jest.mock('../contexts/contexts', () => {
const actual = jest.requireActual('../contexts/contexts');
return {
...actual,
Media: ({children}) => <>{children}</>,
};
});
describe('usePages.setTotal -> totalPages', () => {
// These cases describe the contract: how many pages should the Paginator
// render given a total result count and a per-page limit?
const cases = [
// [total, limit, expectedPages, label]
[0, 24, 1, 'no results -> 1 page'],
[1, 24, 1, 'fewer than one page'],
[23, 24, 1, 'just under one page'],
[24, 24, 1, 'exactly one page (BUG: reports 2)'],
[25, 24, 2, 'one full page plus one'],
[47, 24, 2, 'just under two pages'],
[48, 24, 2, 'exactly two pages (BUG: reports 3)'],
[49, 24, 3, 'just over two pages'],
[72, 24, 3, 'exactly three pages (BUG: reports 4)'],
[100, 24, 5, 'partial last page'],
[144, 24, 6, 'exactly six pages (BUG: reports 7)'],
];
test.each(cases)('total=%i limit=%i -> %i pages (%s)', (total, limit, expected) => {
const wrapper = makeWrapper({l: String(limit)});
const {result} = renderHook(() => usePages(limit), {wrapper});
act(() => {
result.current.setTotal(total);
});
expect(result.current.totalPages).toBe(expected);
});
test('the last reported page yields an offset within total (no empty trailing page)', () => {
// This is the user-visible symptom: clicking the last page link should
// produce an offset that the API can still satisfy. When totalPages is
// off-by-one, the last page maps to offset === total (or > total) and
// the API returns zero rows.
const total = 48;
const limit = 24;
const wrapper = makeWrapper({l: String(limit)});
const {result} = renderHook(() => usePages(limit), {wrapper});
act(() => {
result.current.setTotal(total);
});
act(() => {
result.current.setPage(result.current.totalPages);
});
// setPage stores (page - 1) * limit as `o` in the query.
const lastOffset = (result.current.totalPages - 1) * limit;
expect(lastOffset).toBeLessThan(total);
});
});
describe('useDriveTemperature', () => {
const makeStatusWrapper = (status) => ({children}) => (
<StatusContext.Provider value={{status}}>
{children}
</StatusContext.Provider>
);
test('selects the hottest drive and reads thresholds from the payload', () => {
const status = {
smart_stats: {
drives: [
{device: 'sda', temperature: 51},
{device: 'sdb', temperature: 60},
],
high_temperature: 55,
critical_temperature: 65,
},
};
const {result} = renderHook(() => useDriveTemperature(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.device).toBe('sdb');
expect(result.current.temperature).toBe(60);
expect(result.current.highTemperature).toBe(55);
expect(result.current.criticalTemperature).toBe(65);
});
test('ignores drives without a numeric temperature', () => {
const status = {
smart_stats: {
drives: [
{device: 'sda', temperature: null},
{device: 'sdb', temperature: 48},
],
high_temperature: 55,
critical_temperature: 65,
},
};
const {result} = renderHook(() => useDriveTemperature(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.device).toBe('sdb');
expect(result.current.temperature).toBe(48);
});
test('defaults to no warning and default thresholds when smart_stats is absent', () => {
const {result} = renderHook(() => useDriveTemperature(),
{wrapper: makeStatusWrapper({})});
expect(result.current.device).toBeNull();
expect(result.current.temperature).toBe(0);
expect(result.current.highTemperature).toBe(55);
expect(result.current.criticalTemperature).toBe(65);
});
test('returns no drive when none report a temperature', () => {
const status = {
smart_stats: {
drives: [{device: 'sda', temperature: null}],
high_temperature: 55,
critical_temperature: 65,
},
};
const {result} = renderHook(() => useDriveTemperature(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.device).toBeNull();
expect(result.current.temperature).toBe(0);
});
});
describe('useDriveHealth', () => {
const makeStatusWrapper = (status) => ({children}) => (
<StatusContext.Provider value={{status}}>
{children}
</StatusContext.Provider>
);
test('flags drives whose SMART assessment is FAIL', () => {
const status = {
smart_stats: {
drives: [
{device: 'sda', assessment: 'PASS'},
{device: 'sdb', assessment: 'FAIL'},
],
},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failing).toBe(true);
expect(result.current.failingDevices).toEqual(['sdb']);
});
test('reports every failing drive', () => {
const status = {
smart_stats: {
drives: [
{device: 'sda', assessment: 'FAIL'},
{device: 'sdb', assessment: 'FAIL'},
],
},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failingDevices).toEqual(['sda', 'sdb']);
});
test('does not warn when all drives PASS', () => {
const status = {
smart_stats: {drives: [{device: 'sda', assessment: 'PASS'}]},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failing).toBe(false);
expect(result.current.failingDevices).toEqual([]);
});
test('ignores drives with an unknown (null) assessment', () => {
const status = {
smart_stats: {drives: [{device: 'sda', assessment: null}]},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failing).toBe(false);
});
test('does not warn when smart_stats is absent', () => {
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper({})});
expect(result.current.failing).toBe(false);
expect(result.current.failingDevices).toEqual([]);
});
test('surfaces WARN drives separately from FAIL', () => {
const status = {
smart_stats: {
drives: [
{device: 'sda', health: 'PASS', assessment: 'PASS'},
{device: 'sdb', health: 'WARN', assessment: 'PASS'},
{device: 'sdc', health: 'FAIL', assessment: 'FAIL'},
],
},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failingDevices).toEqual(['sdc']);
expect(result.current.warning).toBe(true);
expect(result.current.warningDevices).toEqual(['sdb']);
});
test('prefers derived health over raw assessment', () => {
// A drive self-assessing PASS but with pending sectors is WARN.
const status = {
smart_stats: {drives: [{device: 'sda', health: 'WARN', assessment: 'PASS'}]},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failing).toBe(false);
expect(result.current.warningDevices).toEqual(['sda']);
});
test('falls back to assessment when health is absent', () => {
const status = {
smart_stats: {drives: [{device: 'sda', assessment: 'FAIL'}]},
};
const {result} = renderHook(() => useDriveHealth(),
{wrapper: makeStatusWrapper(status)});
expect(result.current.failingDevices).toEqual(['sda']);
});
});
describe('Paginator rendering', () => {
test('renders exactly totalPages numbered page links (no phantom trailing link)', () => {
// 48 results at 24 per page -> 2 pages. If totalPages is 3, the pagination
// will render an extra "3" link that fetches an empty page.
render(
<Paginator activePage={1} totalPages={2} onPageChange={() => {}}/>
);
// Page numbers render as <a> elements carrying the page text.
// Tablet + mobile variants both render here because Media is mocked to
// pass children through, so each numeric page appears at least once.
expect(screen.queryAllByText('1').length).toBeGreaterThan(0);
expect(screen.queryAllByText('2').length).toBeGreaterThan(0);
expect(screen.queryByText('3')).toBeNull();
});
});