Files
TREK/client/src/components/Packing/usePackingItemActions.test.ts
T
Maurice 4d4ef17de8 refactor(packing): share the packing list logic between desktop and phone
usePackingItemActions, usePackingTemplates and usePackingImport take the
item, template and bulk import logic out of usePackingListPanel and
MPackingListTab, and the phone listsModel keeps only its priority
tokens.

When adding to a category the desktop looks for the placeholder in the
store and the phone in its rendered items. The desktop toasts on an
empty import, the phone runs one import at a time. The effects run in
the same order as before.
2026-10-09 15:19:41 +02:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildPackingItem } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useTripStore, type TripStoreState } from '../../store/tripStore';
import type { PackingItem } from '../../types';
import type { PackingView } from './packingListModel';
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
import { usePackingItemActions } from './usePackingItemActions';
// FE-PACK-ITEMACT-001 to FE-PACK-ITEMACT-012
const t = (key: string) => key;
const ZWSP = '​';
function actions() {
return {
addPackingItem: vi.fn<TripStoreState['addPackingItem']>(async () => buildPackingItem()),
updatePackingItem: vi.fn<TripStoreState['updatePackingItem']>(async () => buildPackingItem()),
deletePackingItem: vi.fn<TripStoreState['deletePackingItem']>(async () => undefined),
togglePackingItem: vi.fn<TripStoreState['togglePackingItem']>(async () => undefined),
};
}
function setup({
items = [] as PackingItem[],
view = 'common' as PackingView,
categories = [] as string[],
placeholderFrom = 'items' as 'store' | 'items',
currentUserId = 9 as number | null | undefined,
} = {}) {
const acts = actions();
const toast = { error: vi.fn() };
const rendered = renderHook(() =>
usePackingItemActions({
tripId: 7,
items,
view,
currentUserId,
categories,
defaultCategory: 'Other',
actions: acts,
t,
toast,
placeholderFrom,
})
);
return { ...rendered, acts, toast };
}
beforeEach(() => resetAllStores());
describe('usePackingItemActions', () => {
it('FE-PACK-ITEMACT-001: an item added to a category with a placeholder takes over that row', async () => {
const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' });
const { result, acts } = setup({ items: [placeholder] });
await act(async () => {
await result.current.addItemToCategory('Camp', 'Tent');
});
expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' });
expect(acts.addPackingItem).not.toHaveBeenCalled();
});
it('FE-PACK-ITEMACT-002: the desktop looks the placeholder up in the store, the phone in the items it shows', async () => {
const placeholder = buildPackingItem({ id: 4, name: PACKING_PLACEHOLDER_NAME, category: 'Camp' });
seedStore(useTripStore, { packingItems: [placeholder] });
const desktop = setup({ items: [], placeholderFrom: 'store' });
await act(async () => {
await desktop.result.current.addItemToCategory('Camp', 'Tent');
});
expect(desktop.acts.updatePackingItem).toHaveBeenCalledWith(7, 4, { name: 'Tent' });
const phone = setup({ items: [], placeholderFrom: 'items' });
await act(async () => {
await phone.result.current.addItemToCategory('Camp', 'Tent');
});
expect(phone.acts.updatePackingItem).not.toHaveBeenCalled();
expect(phone.acts.addPackingItem).toHaveBeenCalledWith(7, { name: 'Tent', category: 'Camp', visibility: 'common' });
});
it('FE-PACK-ITEMACT-003: in my list a new item is shared the way the category already is (#2241)', async () => {
const own = buildPackingItem({
id: 5,
name: 'Socks',
category: 'Clothes',
is_private: 1,
owner_id: 9,
recipients: [{ user_id: 3, username: 'ada' }],
});
const { result, acts } = setup({ items: [own], view: 'personal' });
await act(async () => {
await result.current.addItemToCategory('Clothes', 'Shirt');
});
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
name: 'Shirt',
category: 'Clothes',
visibility: 'shared',
recipient_ids: [3],
});
});
it('FE-PACK-ITEMACT-004: a failed add is reported', async () => {
const { result, acts, toast } = setup();
acts.addPackingItem.mockRejectedValueOnce(new Error('x'));
await act(async () => {
await result.current.addItemToCategory('Camp', 'Tent');
});
expect(toast.error).toHaveBeenCalledWith('packing.toast.addError');
});
it('FE-PACK-ITEMACT-005: the last real item of a category turns back into its placeholder, unchecked first', async () => {
const last = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes', checked: 1 });
const { result, acts } = setup({ items: [last] });
await act(async () => {
await result.current.deleteItem(last);
});
expect(acts.togglePackingItem).toHaveBeenCalledWith(7, 5, false);
expect(acts.updatePackingItem).toHaveBeenCalledWith(7, 5, {
name: PACKING_PLACEHOLDER_NAME,
weight_grams: null,
bag_id: null,
quantity: 1,
});
expect(acts.deletePackingItem).not.toHaveBeenCalled();
expect(acts.togglePackingItem.mock.invocationCallOrder[0]).toBeLessThan(
acts.updatePackingItem.mock.invocationCallOrder[0]
);
});
it('FE-PACK-ITEMACT-006: any other item is deleted, and a failure is reported', async () => {
const rope = buildPackingItem({ id: 5, name: 'Rope', category: 'Ropes' });
const knot = buildPackingItem({ id: 6, name: 'Knot', category: 'Ropes' });
const { result, acts, toast } = setup({ items: [rope, knot] });
await act(async () => {
await result.current.deleteItem(rope);
});
expect(acts.deletePackingItem).toHaveBeenCalledWith(7, 5);
expect(acts.togglePackingItem).not.toHaveBeenCalled();
acts.deletePackingItem.mockRejectedValueOnce(new Error('x'));
await act(async () => {
await result.current.deleteItem(rope);
});
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
});
it('FE-PACK-ITEMACT-007: renaming moves every item of the category, the uncategorised under the default name', async () => {
const a = buildPackingItem({ id: 1, category: 'Camp' });
const b = buildPackingItem({ id: 2, category: null });
const c = buildPackingItem({ id: 3, category: 'Camp' });
const { result, acts } = setup({ items: [a, b, c] });
await act(async () => {
await result.current.renameCategory('Camp', 'Outdoor');
});
expect(acts.updatePackingItem.mock.calls).toEqual([
[7, 1, { category: 'Outdoor' }],
[7, 3, { category: 'Outdoor' }],
]);
acts.updatePackingItem.mockClear();
await act(async () => {
await result.current.renameCategory('Other', 'Misc');
});
expect(acts.updatePackingItem.mock.calls).toEqual([[7, 2, { category: 'Misc' }]]);
});
it('FE-PACK-ITEMACT-008: a failed rename stops: the desktop gets the error, the phone a message', async () => {
const a = buildPackingItem({ id: 1, category: 'Camp' });
const c = buildPackingItem({ id: 3, category: 'Camp' });
const { result, acts, toast } = setup({ items: [a, c] });
acts.updatePackingItem.mockRejectedValueOnce(new Error('x'));
await expect(result.current.renameCategory('Camp', 'Outdoor')).rejects.toThrow('x');
expect(acts.updatePackingItem).toHaveBeenCalledTimes(1);
expect(toast.error).not.toHaveBeenCalled();
acts.updatePackingItem.mockClear();
acts.updatePackingItem.mockRejectedValueOnce(new Error('x'));
await act(async () => {
await result.current.renameCategoryReporting('Camp', 'Outdoor');
});
expect(acts.updatePackingItem).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith('packing.toast.renameError');
});
it('FE-PACK-ITEMACT-009: emptying a category tries every item and reports a failure once', async () => {
const items = [1, 2, 3].map((id) => buildPackingItem({ id, category: 'Camp' }));
const { result, acts, toast } = setup({ items });
acts.deletePackingItem.mockRejectedValueOnce(new Error('x')).mockRejectedValueOnce(new Error('y'));
await act(async () => {
await result.current.deleteCategoryItems(items);
});
expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 2, 3]);
expect(toast.error).toHaveBeenCalledTimes(1);
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
});
it('FE-PACK-ITEMACT-010: clearing removes every checked item of the trip, in both views', async () => {
const items = [
buildPackingItem({ id: 1, checked: 1 }),
buildPackingItem({ id: 2, checked: 0 }),
buildPackingItem({ id: 3, checked: 1, is_private: 1 }),
];
const { result, acts, toast } = setup({ items });
await act(async () => {
await result.current.clearChecked();
});
expect(acts.deletePackingItem.mock.calls.map((c) => c[1])).toEqual([1, 3]);
expect(toast.error).not.toHaveBeenCalled();
acts.deletePackingItem.mockRejectedValueOnce(new Error('x'));
await act(async () => {
await result.current.clearChecked();
});
expect(toast.error).toHaveBeenCalledWith('packing.toast.deleteError');
});
it('FE-PACK-ITEMACT-011: a new category needs a name, and a taken one gets an invisible suffix', async () => {
const { result, acts } = setup({ categories: ['Camp', `Camp${ZWSP}`], view: 'personal' });
await act(async () => {
await result.current.addNewCategory();
});
expect(acts.addPackingItem).not.toHaveBeenCalled();
act(() => {
result.current.setAddingCategory(true);
result.current.setNewCategoryName(' Camp ');
});
await act(async () => {
await result.current.addNewCategory();
});
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
name: PACKING_PLACEHOLDER_NAME,
category: `Camp${ZWSP}${ZWSP}`,
visibility: 'personal',
});
expect(result.current.newCategoryName).toBe('');
expect(result.current.addingCategory).toBe(false);
});
it('FE-PACK-ITEMACT-012: a failed new category is reported and keeps the field open', async () => {
const { result, acts, toast } = setup();
acts.addPackingItem.mockRejectedValueOnce(new Error('x'));
act(() => {
result.current.setAddingCategory(true);
result.current.setNewCategoryName('Camp');
});
await act(async () => {
await result.current.addNewCategory();
});
expect(acts.addPackingItem).toHaveBeenCalledWith(7, {
name: PACKING_PLACEHOLDER_NAME,
category: 'Camp',
visibility: 'common',
});
expect(toast.error).toHaveBeenCalledWith('packing.toast.addError');
expect(result.current.newCategoryName).toBe('Camp');
expect(result.current.addingCategory).toBe(true);
});
});