wrolpi/app/src/components/inventory/InventoryTable.test.js
Roland 906705e5b4 ui: thirty-five files off Semantic, leaving fifteen
Phase 2, part 4.  Baseline 50 -> 15.

Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.

Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message.  useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.

Two library gaps closed, both found by call sites needing them:

  ActionInput is a forwardRef now.  Its ref reaches the input rather than the
  wrapper, because callers use it to focus or select the field -- a ref to the
  surrounding div would do neither, which is why one call site had resorted to
  hand-copying the component's markup.

  Header takes a `color` token name.  Three separate call sites had reached for
  an inline style to colour a heading that carries meaning ("3 items to add"
  against "2 to remove"), which is how a hex ends up in markup.

Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used.  Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.

Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads.  Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
2026-07-30 12:54:24 -06:00

352 lines
17 KiB
JavaScript

import React from "react";
import {fireEvent, render, screen, within} from "../../test-utils";
import {filterItems, InventoryTable} from "./InventoryTable";
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'count', label: 'Count', type: 'number', order: 1},
];
describe('InventoryTable keyboard entry', () => {
test('pressing Enter in the draft row submits the new item via onChange', () => {
const onChange = jest.fn();
render(<InventoryTable slug='food-storage' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name')[0];
fireEvent.change(nameInput, {target: {value: 'Salt'}});
fireEvent.keyDown(nameInput, {key: 'Enter'});
expect(onChange).toHaveBeenCalledTimes(1);
const newItems = onChange.mock.calls[0][0];
expect(newItems).toHaveLength(1);
expect(newItems[0].name).toBe('Salt');
expect(typeof newItems[0].id).toBe('number');
});
test('does not submit an empty draft row', () => {
const onChange = jest.fn();
render(<InventoryTable slug='food-storage' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name')[0];
fireEvent.keyDown(nameInput, {key: 'Enter'});
expect(onChange).not.toHaveBeenCalled();
});
test('new item id is unique relative to existing items', () => {
const onChange = jest.fn();
const items = [{id: 7, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
const nameInput = screen.getAllByLabelText('Name').slice(-1)[0]; // the draft row's input
fireEvent.change(nameInput, {target: {value: 'Rice'}});
fireEvent.keyDown(nameInput, {key: 'Enter'});
const newItems = onChange.mock.calls[0][0];
expect(newItems).toHaveLength(2);
expect(newItems[1].id).toBe(8);
});
test('clicking an existing item cell starts inline edit', () => {
const items = [{id: 1, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Salt'));
const editInputs = screen.getAllByLabelText('Name').map(i => i.value);
expect(editInputs).toContain('Salt');
});
test('clicking a cell focuses its input with the contents selected', () => {
const items = [{id: 1, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Salt'));
const input = screen.getAllByLabelText('Name').find(i => i.value === 'Salt');
// The clicked cell's input is focused and its whole value is selected, ready to be typed over.
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('Salt'.length);
});
test('clicking a different field focuses that field, not the first one', () => {
const items = [{id: 1, name: 'Salt', count: '5'}];
render(<InventoryTable slug='food-storage' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('5')); // the Count cell
const countInput = screen.getAllByLabelText('Count').find(i => i.value === '5');
expect(document.activeElement).toBe(countInput);
});
});
describe('InventoryTable count by weight', () => {
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g', order: 1},
{key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g', order: 2},
{key: 'count', label: 'Count', type: 'number', order: 3,
compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}},
];
test('entering the two weights in the draft row auto-fills the count', () => {
const onChange = jest.fn();
render(<InventoryTable slug='s' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}});
fireEvent.change(screen.getByLabelText('Total Weight'), {target: {value: '1000'}});
// The draft Count input is now pre-filled with 200 (1000 / 5).
expect(screen.getByLabelText('Count').value).toBe('200');
});
test('editing an existing item recomputes its count from the weights', () => {
const onChange = jest.fn();
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
fireEvent.click(screen.getByText('Screws')); // enter edit mode
const totalInput = screen.getAllByLabelText('Total Weight').find(i => i.value === '1000');
fireEvent.change(totalInput, {target: {value: '2000'}});
// The edited row's Count is recomputed to 400 (2000 / 5).
expect(screen.getAllByLabelText('Count').some(i => i.value === '400')).toBe(true);
});
test('clearing a weight in an existing row blanks the count (no stale value)', () => {
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Screws')); // enter edit mode
const row = screen.getByDisplayValue('Screws').closest('tr');
// Clearing the total weight (as a real keyboard delete does) fires onChange with '' and blanks the count.
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: ''}});
expect(within(row).getByLabelText('Count').value).toBe('');
});
test('a math expression in a weight evaluates on blur and recomputes the count', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={[]} locations={[]} onChange={jest.fn()}/>);
fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}});
const total = screen.getByLabelText('Total Weight');
fireEvent.change(total, {target: {value: '400 - 20'}}); // weigh a 400 g box, subtract the 20 g box
fireEvent.blur(total);
expect(screen.getByLabelText('Total Weight').value).toBe('380');
expect(screen.getByLabelText('Count').value).toBe('76'); // 380 / 5
});
test('Enter resolves a weight expression before the row can be submitted', () => {
const onChange = jest.fn();
render(<InventoryTable slug='s' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}});
const total = screen.getByLabelText('Total Weight');
fireEvent.change(total, {target: {value: '400 - 20'}});
// First Enter resolves the expression; the row is NOT submitted yet.
fireEvent.keyDown(total, {key: 'Enter'});
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByLabelText('Total Weight').value).toBe('380');
// Second Enter (now a plain number) submits the resolved row with the recomputed count.
fireEvent.keyDown(screen.getByLabelText('Total Weight'), {key: 'Enter'});
const item = onChange.mock.calls[0][0][0];
expect(item.total_weight).toBe('380');
expect(item.count).toBe('76');
});
test('Enter submits an existing row from an unfilled numeric cell (null backing value)', () => {
const onChange = jest.fn();
// count is absent (undefined) — an item field that was never filled in.
const items = [{id: 1, name: 'Salt'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
fireEvent.click(screen.getByText('Salt')); // enter edit mode
const row = screen.getByDisplayValue('Salt').closest('tr');
// A null/undefined value must not be mistaken for an unresolved expression; Enter submits immediately.
fireEvent.keyDown(within(row).getByLabelText('Count'), {key: 'Enter'});
expect(onChange).toHaveBeenCalledTimes(1);
});
test('reducing a weight below one item shows 0, not the stale count', () => {
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Screws'));
const row = screen.getByDisplayValue('Screws').closest('tr');
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: '1'}});
expect(within(row).getByLabelText('Count').value).toBe('0');
});
});
describe('InventoryTable sorting', () => {
const items = [
{id: 1, name: 'Rice', count: '5'},
{id: 2, name: 'Beans', count: '20'},
{id: 3, name: 'Apples', count: '12'},
];
const bodyNames = () =>
[...document.querySelectorAll('tbody tr')].map(tr => tr.querySelector('td:nth-child(2)').textContent);
test('clicking a text header sorts ascending then descending', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
expect(bodyNames()).toEqual(['Rice', 'Beans', 'Apples']); // entry order
fireEvent.click(screen.getByText('Name'));
expect(bodyNames()).toEqual(['Apples', 'Beans', 'Rice']);
fireEvent.click(screen.getByText('Name'));
expect(bodyNames()).toEqual(['Rice', 'Beans', 'Apples']);
});
test('numeric column sorts numerically, not lexically', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
fireEvent.click(screen.getByText('Count'));
// 5 < 12 < 20 numerically (lexical would put '12','20','5').
expect(bodyNames()).toEqual(['Rice', 'Apples', 'Beans']);
});
});
describe('InventoryTable catalog pre-fill', () => {
const CAT_FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'category', label: 'Category', type: 'text', order: 1},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 2},
{key: 'calories', label: 'kcal', type: 'calories', order: 3},
];
const CATALOG = [
{id: 1, name: 'White Rice', category: 'grains', subcategory: 'rice',
item_size: '5', item_size_unit: 'lb', calories: '8040'},
];
test('selecting a catalog name pre-fills the matching fields', () => {
render(<InventoryTable slug='s' fields={CAT_FIELDS} items={[]} locations={[]} catalog={CATALOG}
onChange={jest.fn()}/>);
// The catalog datalist is present.
expect(document.querySelector('#inventory-catalog-names option[value="White Rice"]')).toBeTruthy();
fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'White Rice'}});
expect(screen.getByLabelText('Category').value).toBe('grains');
expect(screen.getByLabelText('Size').value).toBe('5');
expect(screen.getByLabelText('kcal').value).toBe('8040');
});
test('a non-catalog name leaves other fields untouched', () => {
render(<InventoryTable slug='s' fields={CAT_FIELDS} items={[]} locations={[]} catalog={CATALOG}
onChange={jest.fn()}/>);
fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'Homemade Jerky'}});
expect(screen.getByLabelText('Category').value).toBe('');
expect(screen.getByLabelText('kcal').value).toBe('');
});
});
describe('InventoryTable expired highlighting', () => {
const FIELDS_WITH_DATE = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 1},
];
test('rows past a date field are flagged negative with a warning icon', () => {
const items = [
{id: 1, name: 'Old', expiration_date: '2000-01-01'},
{id: 2, name: 'Fresh', expiration_date: '2999-01-01'},
];
render(
<InventoryTable slug='s' fields={FIELDS_WITH_DATE} items={items} locations={[]} onChange={jest.fn()}/>);
const rowOf = (name) => screen.getByText(name).closest('tr');
expect(rowOf('Old').className).toContain('wrolpi-row-failed');
expect(rowOf('Fresh').className).not.toContain('wrolpi-row-failed');
// One expired row -> one warning icon.
expect(screen.getAllByLabelText('Expired').length).toBe(1);
});
});
describe('filterItems', () => {
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'category', label: 'Category', type: 'select', order: 1},
{key: 'item_size', label: 'Size', type: 'quantity', unit: 'lb', order: 2},
{key: 'count', label: 'Count', type: 'number', order: 3},
{key: 'expiration_date', label: 'Expires', type: 'date', order: 4},
];
const ITEMS = [
{id: 1, name: 'White Rice', category: 'grains', item_size: '30', item_size_unit: 'lb', count: '3',
expiration_date: '2030-01-01'},
{id: 2, name: 'Pinto Beans', category: 'legumes', item_size: '25', item_size_unit: 'lb', count: '2',
expiration_date: '2033-01-01'},
{id: 3, name: 'Canned Chicken', category: 'meats', item_size: '12.5', item_size_unit: 'oz', count: '12',
expiration_date: '2030-06-01'},
];
const names = (rows) => rows.map(r => r.name);
test('blank or whitespace search returns all items', () => {
expect(filterItems(ITEMS, FIELDS, '')).toHaveLength(3);
expect(filterItems(ITEMS, FIELDS, ' ')).toHaveLength(3);
});
test('matches a text column, case-insensitively', () => {
expect(names(filterItems(ITEMS, FIELDS, 'rice'))).toEqual(['White Rice']);
expect(names(filterItems(ITEMS, FIELDS, 'RICE'))).toEqual(['White Rice']);
});
test('matches a select column (category)', () => {
expect(names(filterItems(ITEMS, FIELDS, 'legumes'))).toEqual(['Pinto Beans']);
});
test('matches a date column', () => {
expect(names(filterItems(ITEMS, FIELDS, '2030'))).toEqual(['White Rice', 'Canned Chicken']);
});
test('matches a quantity column by its formatted value', () => {
expect(names(filterItems(ITEMS, FIELDS, '25 lb'))).toEqual(['Pinto Beans']);
});
test('whitespace-separated terms are AND-ed', () => {
expect(names(filterItems(ITEMS, FIELDS, 'canned chicken'))).toEqual(['Canned Chicken']);
expect(filterItems(ITEMS, FIELDS, 'rice beans')).toEqual([]);
});
test('a non-matching search returns nothing', () => {
expect(filterItems(ITEMS, FIELDS, 'xyzzy')).toEqual([]);
});
});
describe('InventoryTable search', () => {
const FIELDS = [
{key: 'name', label: 'Name', type: 'text', order: 0},
{key: 'count', label: 'Count', type: 'number', order: 1},
];
const items = [
{id: 1, name: 'White Rice', count: '3'},
{id: 2, name: 'Pinto Beans', count: '2'},
];
test('only matching rows are rendered, and the entry row remains', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} search='rice'
onChange={jest.fn()}/>);
expect(screen.getByText('White Rice')).toBeTruthy();
expect(screen.queryByText('Pinto Beans')).toBeNull();
// The persistent draft entry row is still present.
expect(screen.getAllByLabelText('Name').length).toBeGreaterThan(0);
});
test('adding from the entry row while filtered preserves the hidden items', () => {
const onChange = jest.fn();
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} search='rice'
onChange={onChange}/>);
const draftName = screen.getAllByLabelText('Name').slice(-1)[0];
fireEvent.change(draftName, {target: {value: 'Oats'}});
fireEvent.keyDown(draftName, {key: 'Enter'});
// onChange gets the FULL inventory (both originals + new), not just the filtered view.
const newItems = onChange.mock.calls[0][0];
expect(newItems.map(i => i.name)).toEqual(['White Rice', 'Pinto Beans', 'Oats']);
});
test('a non-matching search shows a no-match message', () => {
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} search='xyzzy'
onChange={jest.fn()}/>);
expect(screen.getByText(/no items match/i)).toBeTruthy();
});
});