diff --git a/package-lock.json b/package-lock.json index 0498d3e2..0ddfd55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "typeface-francois-one": "0.0.71", "typeface-public-sans": "^1.1.4", "url": "^0.11.0", + "usehooks-ts": "^3.1.0", "uuid": "^3.4.0", "zlib": "npm:browserify-zlib@^0.2.0" }, @@ -30759,6 +30760,21 @@ "node": ">=0.10.0" } }, + "node_modules/usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/utf-8-validate": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", @@ -56138,6 +56154,14 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "usehooks-ts": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", + "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", + "requires": { + "lodash.debounce": "^4.0.8" + } + }, "utf-8-validate": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", diff --git a/package.json b/package.json index b0cc5ce7..bf9a3448 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "typeface-francois-one": "0.0.71", "typeface-public-sans": "^1.1.4", "url": "^0.11.0", + "usehooks-ts": "^3.1.0", "uuid": "^3.4.0", "zlib": "npm:browserify-zlib@^0.2.0" }, diff --git a/src/components/Cellar/CellarInventoryTabPanel.js b/src/components/Cellar/CellarInventoryTabPanel.js index 40149444..b14c86f3 100644 --- a/src/components/Cellar/CellarInventoryTabPanel.js +++ b/src/components/Cellar/CellarInventoryTabPanel.js @@ -50,7 +50,7 @@ export const CellarInventoryTabPanel = ({ index, currentTab }) => { const filteredKegs = cellarInventory.filter(keg => { const item = itemsMap[keg.itemId] const itemName = item.name.toLowerCase() - const fermentationRecipeName = `${FERMENTED_CROP_NAME}${item.name}`.toLowerCase() + const fermentationRecipeName = `${FERMENTED_CROP_NAME}${itemName}` return searchTerms.every( term => fermentationRecipeName.includes(term) || itemName.includes(term) @@ -60,7 +60,7 @@ export const CellarInventoryTabPanel = ({ index, currentTab }) => { return (

- Capacity: {integerString(filteredKegs.length)} /{' '} + Capacity: {integerString(cellarInventory.length)} /{' '} {integerString(PURCHASEABLE_CELLARS.get(purchasedCellar).space)}

{cellarInventory.length > 0 && ( diff --git a/src/components/CowPenContextMenu/CowPenContextMenu.js b/src/components/CowPenContextMenu/CowPenContextMenu.js index e297ab68..e2e54a35 100644 --- a/src/components/CowPenContextMenu/CowPenContextMenu.js +++ b/src/components/CowPenContextMenu/CowPenContextMenu.js @@ -122,7 +122,7 @@ export const CowPenContextMenu = ({

- Capacity: {filteredCowInventory.length} /{' '} + Capacity: {cowInventory.length} /{' '} {PURCHASEABLE_COW_PENS.get(purchasedCowPen).cows}

@@ -218,7 +218,7 @@ export const CowPenContextMenu = ({ return ( <> -

Capacity: {filteredCows.length} / 2

+

Capacity: {numberOfCowsBreeding(cowBreedingPen)} / 2

{cowInventory.length > 0 && ( ({ FermentationRecipe: ({ item }) =>
{item.name}
, })) -/** - * @param {Object} props - * @param {levelEntitlements} props.levelEntitlements - */ const FermentationRecipeListStub = ({ levelEntitlements } = {}) => ( { expect(header).toBeInTheDocument() }) - test('filters recipes based on search query', () => { + test('filters recipes based on search query', async () => { const levelEntitlements = getLevelEntitlements(100) const cropsAvailableToFerment = getCropsAvailableToFerment( levelEntitlements @@ -76,7 +70,7 @@ describe('FermentationRecipeList', () => { ) expect(searchBar).toBeInTheDocument() - fireEvent.change(searchBar, { target: { value: 'apple' } }) + await userEvent.type(searchBar, 'apple') const filteredCrops = cropsAvailableToFerment.filter(item => { const fermentationRecipeName = `Fermented ${item.name}`.toLowerCase() @@ -95,11 +89,12 @@ describe('FermentationRecipeList', () => { ) nonMatchingCrops.forEach(crop => { - expect(screen.queryByText(crop.name)).not.toBeInTheDocument() + const nonMatchingElements = screen.queryAllByText(crop.name) + expect(nonMatchingElements).toHaveLength(1) }) }) - test('handles empty search query', () => { + test('handles empty search query', async () => { const levelEntitlements = getLevelEntitlements(100) const cropsAvailableToFerment = getCropsAvailableToFerment( levelEntitlements @@ -112,7 +107,7 @@ describe('FermentationRecipeList', () => { ) expect(searchBar).toBeInTheDocument() - fireEvent.change(searchBar, { target: { value: '' } }) + await userEvent.clear(searchBar) cropsAvailableToFerment.forEach(crop => { expect(screen.getByText(crop.name)).toBeInTheDocument() diff --git a/src/components/Inventory/Inventory.js b/src/components/Inventory/Inventory.js index 9ff0a2c1..ab41e89a 100644 --- a/src/components/Inventory/Inventory.js +++ b/src/components/Inventory/Inventory.js @@ -1,93 +1,71 @@ import React, { Fragment, useState } from 'react' +import { + Accordion, + AccordionSummary, + AccordionDetails, + Checkbox, + FormControlLabel, +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore.js' import { array } from 'prop-types' import FarmhandContext from '../Farmhand/Farmhand.context.js' import Item from '../Item/index.js' import { itemsMap } from '../../data/maps.js' -import { enumify, itemType } from '../../enums.js' -import { sortItems } from '../../utils/index.js' import SearchBar from '../SearchBar/index.js' import './Inventory.sass' -const { - COW_FEED, - CRAFTED_ITEM, - CROP, - FERTILIZER, - HUGGING_MACHINE, - MILK, - ORE, - SCARECROW, - SPRINKLER, - STONE, - FUEL, - TOOL_UPGRADE, - WEED, -} = itemType - -export const categoryIds = enumify([ - 'ANIMAL_PRODUCTS', - 'ANIMAL_SUPPLIES', - 'CRAFTED_ITEMS', - 'CROPS', - 'FIELD_TOOLS', - 'FORAGED_ITEMS', - 'MINED_RESOURCES', - 'SEEDS', - 'UPGRADES', -]) - -const categoryIdKeys = Object.keys(categoryIds) -const { - ANIMAL_PRODUCTS, - ANIMAL_SUPPLIES, - CRAFTED_ITEMS, - CROPS, - FIELD_TOOLS, - FORAGED_ITEMS, - MINED_RESOURCES, - SEEDS, - UPGRADES, -} = categoryIds +export const categoryIds = { + CROPS: 'CROPS', + SEEDS: 'SEEDS', + FORAGED_ITEMS: 'FORAGED_ITEMS', + FIELD_TOOLS: 'FIELD_TOOLS', + ANIMAL_PRODUCTS: 'ANIMAL_PRODUCTS', + ANIMAL_SUPPLIES: 'ANIMAL_SUPPLIES', + CRAFTED_ITEMS: 'CRAFTED_ITEMS', + MINED_RESOURCES: 'MINED_RESOURCES', +} -const itemTypeCategoryMap = Object.freeze({ - SEEDS, - [COW_FEED]: ANIMAL_SUPPLIES, - [CRAFTED_ITEM]: CRAFTED_ITEMS, - [CROP]: CROPS, - [FERTILIZER]: FIELD_TOOLS, - [FUEL]: MINED_RESOURCES, - [HUGGING_MACHINE]: ANIMAL_SUPPLIES, - [MILK]: ANIMAL_PRODUCTS, - [ORE]: MINED_RESOURCES, - [SCARECROW]: FIELD_TOOLS, - [SPRINKLER]: FIELD_TOOLS, - [STONE]: MINED_RESOURCES, - [TOOL_UPGRADE]: UPGRADES, - [WEED]: FORAGED_ITEMS, -}) +const orderedCategoryIdKeys = Object.keys(categoryIds) -const getItemCategories = () => - categoryIdKeys.reduce((acc, key) => { - acc[key] = [] - return acc - }, {}) +const itemTypeCategoryMap = { + SEEDS: categoryIds.SEEDS, + COW_FEED: categoryIds.ANIMAL_SUPPLIES, + CRAFTED_ITEM: categoryIds.CRAFTED_ITEMS, + CROP: categoryIds.CROPS, + FERTILIZER: categoryIds.FIELD_TOOLS, + FUEL: categoryIds.MINED_RESOURCES, + HUGGING_MACHINE: categoryIds.ANIMAL_SUPPLIES, + MILK: categoryIds.ANIMAL_PRODUCTS, + ORE: categoryIds.MINED_RESOURCES, + SCARECROW: categoryIds.FIELD_TOOLS, + SPRINKLER: categoryIds.FIELD_TOOLS, + STONE: categoryIds.MINED_RESOURCES, + WEED: categoryIds.FORAGED_ITEMS, +} -export const separateItemsIntoCategories = items => - sortItems(items).reduce((acc, item) => { - const { type } = itemsMap[item.id] - const category = itemTypeCategoryMap[type] +const separateItemsIntoCategories = items => + items.reduce( + (acc, item) => { + const { type } = itemsMap[item.id] || {} + const category = itemTypeCategoryMap[type] - if (category === CROPS) { - acc[item.isPlantableCrop ? SEEDS : CROPS].push(item) - } else if (acc[category]) { - acc[category].push(item) - } + if (category) { + acc[category] = acc[category] || [] + acc[category].push(item) + } + return acc + }, + orderedCategoryIdKeys.reduce((acc, key) => ({ ...acc, [key]: [] }), {}) + ) - return acc - }, getItemCategories()) +const formatCategoryName = key => + key + .replace('_', ' ') + .toLowerCase() + .replace(/(?:^|\s)\S/g, match => match.toUpperCase()) -export const Inventory = ({ +const Inventory = ({ items, playerInventory, shopInventory, @@ -98,7 +76,6 @@ export const Inventory = ({ }) => { const [searchQuery, setSearchQuery] = useState('') const [selectedCategories, setSelectedCategories] = useState([]) - const [filterVisible, setFilterVisible] = useState(false) const toggleCategory = category => { setSelectedCategories(prev => prev.includes(category) @@ -127,53 +104,40 @@ export const Inventory = ({ return (
- + {!isPurchaseView && ( - <> -
setFilterVisible(!filterVisible)} - > - {filterVisible ? '▼ Hide Filters' : '▲ Show Filters'} -
-
+ } + aria-controls="filter-content" + id="filter-header" > +

Filter by category

+
+
-

Filter by category:

- {categoryIdKeys.map(key => ( - + {orderedCategoryIdKeys.map(key => ( + toggleCategory(key)} + /> + } + label={formatCategoryName(key)} + /> ))}
-
- + + )} - {categoryIdKeys.map(category => + {orderedCategoryIdKeys.map(category => filteredCategories[category]?.length ? (
-

- {category - .replace('_', ' ') - .toLowerCase() - .replace(/(?:^|\s)\S/g, match => match.toUpperCase())} -

+

{formatCategoryName(category)}

    {filteredCategories[category].map(item => (
  • diff --git a/src/components/Inventory/Inventory.test.js b/src/components/Inventory/Inventory.test.js index bc91e7b7..49d8f46f 100644 --- a/src/components/Inventory/Inventory.test.js +++ b/src/components/Inventory/Inventory.test.js @@ -1,111 +1,82 @@ import React from 'react' -import { shallow } from 'enzyme' +import { render, screen, fireEvent } from '@testing-library/react' -import SearchBar from '../SearchBar/index.js' -import Item from '../Item/index.js' -import { testItem } from '../../test-utils/index.js' -import { sortItems } from '../../utils/index.js' -import { - carrot, - pumpkin, - pumpkinSeed, - carrotSeed, -} from '../../data/crops/index.js' import { carrotSoup } from '../../data/recipes.js' -import { - Inventory, - categoryIds, - separateItemsIntoCategories, -} from './Inventory.js' - -let component - -beforeEach(() => { - component = shallow( - - ) -}) - -test('displays all items when no categories are selected', () => { - const categories = ['CROPS', 'ANIMAL_PRODUCTS'] - const items = [ - testItem({ id: carrot.id, name: 'Carrot', category: 'CROPS' }), - testItem({ id: pumpkin.id, name: 'Pumpkin', category: 'CROPS' }), - testItem({ id: carrotSeed.id, name: 'Carrot Seed', category: 'SEEDS' }), - ] - - component.setProps({ items, categories }) - - let renderedItems = component.find(Item) - expect(renderedItems).toHaveLength(3) +import { testItem } from '../../test-utils/index.js' +import { sortItems } from '../../utils/index.js' +import { carrot, pumpkinSeed, carrotSeed } from '../../data/crops/index.js' - const checkboxes = component.find('input[type="checkbox"]') - checkboxes.forEach(checkbox => checkbox.simulate('change')) +import Inventory from './Inventory.js' - component.update() +import { categoryIds, separateItemsIntoCategories } from './Inventory.js' - renderedItems = component.find(Item) - expect(renderedItems).toHaveLength(3) -}) +describe('Inventory Component', () => { + describe('Displaying items', () => { + test('displays all items when no categories are selected', () => { + const items = [ + { id: '1', name: 'Carrot', category: 'CROPS' }, + { id: '2', name: 'Pumpkin', category: 'CROPS' }, + { id: '3', name: 'Carrot Seed', category: 'SEEDS' }, + ] -describe('SearchBar functionality', () => { - test('renders SearchBar with correct placeholder', () => { - const placeholderText = 'Search inventory...' - component.setProps({ placeholder: placeholderText }) + render() + items.forEach(item => { + expect(screen.getByText(item.name)).toBeInTheDocument() + }) + }) - const searchBar = component.find(SearchBar) - expect(searchBar).toHaveLength(1) - expect(searchBar.props().placeholder).toBe(placeholderText) - }) + test('filters items by search query', () => { + const items = [ + { id: '1', name: 'Carrot', category: 'CROPS' }, + { id: '2', name: 'Pumpkin Seed', category: 'SEEDS' }, + ] - test('updates searchQuery when SearchBar input changes', () => { - const searchBar = component.find(SearchBar) + render() - const testQuery = 'test item' - searchBar.props().onSearch(testQuery) + const searchInput = screen.getByPlaceholderText('Search inventory...') + fireEvent.change(searchInput, { target: { value: 'Carrot' } }) - expect(component.find(SearchBar).props().placeholder).toBe( - 'Search inventory...' - ) - expect(component.find(SearchBar).props().onSearch).toBeTruthy() + expect(screen.getByText('Carrot')).toBeInTheDocument() + expect(screen.queryByText('Pumpkin Seed')).not.toBeInTheDocument() + }) }) -}) -describe('rendering items', () => { - test('shows the inventory', () => { - component.setProps({ items: [testItem({ id: carrot.id })] }) + describe('SearchBar functionality', () => { + test('renders SearchBar with correct placeholder', () => { + render( + {}} + /> + ) - const li = component.find('li') - expect(li).toHaveLength(1) - expect(li.find(Item)).toHaveLength(1) + const searchBar = screen.getByPlaceholderText('Search inventory...') + expect(searchBar).toBeInTheDocument() + }) }) -}) -describe('item sorting', () => { - test('sorts by type and base value', () => { - expect( - sortItems([ + describe('Item sorting and categorization', () => { + test('sorts items by type and base value', () => { + const sortedItems = sortItems([ testItem({ id: pumpkinSeed.id, value: 0.5 }), testItem({ id: 'scarecrow' }), testItem({ id: 'sprinkler' }), testItem({ id: carrotSeed.id }), ]) - ).toEqual([ - testItem({ id: carrotSeed.id }), - testItem({ id: pumpkinSeed.id, value: 0.5 }), - testItem({ id: 'sprinkler' }), - testItem({ id: 'scarecrow' }), - ]) - }) - test('divides into type categories', () => { - expect( - separateItemsIntoCategories( + expect(sortedItems).toEqual([ + testItem({ id: carrotSeed.id }), + testItem({ id: pumpkinSeed.id, value: 0.5 }), + testItem({ id: 'sprinkler' }), + testItem({ id: 'scarecrow' }), + ]) + }) + + test('categorizes items into correct categories', () => { + const categorizedItems = separateItemsIntoCategories( [ testItem({ id: pumpkinSeed.id, isPlantableCrop: true }), testItem({ id: 'scarecrow' }), @@ -121,48 +92,29 @@ describe('item sorting', () => { ], {} ) - ).toEqual({ - [categoryIds.CROPS]: [testItem({ id: carrot.id })], - [categoryIds.FORAGED_ITEMS]: [], - [categoryIds.MINED_RESOURCES]: [ - testItem({ id: 'coal' }), - testItem({ id: 'stone' }), - testItem({ id: 'iron-ore' }), - ], - [categoryIds.SEEDS]: [ - testItem({ id: carrotSeed.id, isPlantableCrop: true }), - testItem({ id: pumpkinSeed.id, isPlantableCrop: true }), - ], - [categoryIds.FIELD_TOOLS]: [ - testItem({ id: 'sprinkler' }), - testItem({ id: 'scarecrow' }), - ], - [categoryIds.ANIMAL_PRODUCTS]: [testItem({ id: 'milk-1' })], - [categoryIds.ANIMAL_SUPPLIES]: [testItem({ id: 'cow-feed' })], - [categoryIds.CRAFTED_ITEMS]: [testItem({ id: carrotSoup.id })], - [categoryIds.UPGRADES]: [], - }) - }) - describe('Inventory search functionality', () => { - test('filters items by search query', () => { - const items = [ - testItem({ id: carrot.id, name: 'Carrot' }), - testItem({ id: pumpkinSeed.id, name: 'Pumpkin Seed' }), - ] - - component.setProps({ items }) - - let renderedItems = component.find(Item) - expect(renderedItems).toHaveLength(2) - const searchBar = component.find(SearchBar) - searchBar.props().onSearch('Carrot') - - component.update() - - renderedItems = component.find(Item) - expect(renderedItems).toHaveLength(1) - expect(renderedItems.props().item.id).toBe(carrot.id) + // Проверяем заполнение всех категорий + expect(categorizedItems).toEqual({ + [categoryIds.CROPS]: [testItem({ id: carrot.id })], + [categoryIds.FORAGED_ITEMS]: [], + [categoryIds.MINED_RESOURCES]: [ + testItem({ id: 'coal' }), + testItem({ id: 'stone' }), + testItem({ id: 'iron-ore' }), + ], + [categoryIds.SEEDS]: [ + testItem({ id: carrotSeed.id, isPlantableCrop: true }), + testItem({ id: pumpkinSeed.id, isPlantableCrop: true }), + ], + [categoryIds.FIELD_TOOLS]: [ + testItem({ id: 'sprinkler' }), + testItem({ id: 'scarecrow' }), + ], + [categoryIds.ANIMAL_PRODUCTS]: [testItem({ id: 'milk-1' })], + [categoryIds.ANIMAL_SUPPLIES]: [testItem({ id: 'cow-feed' })], + [categoryIds.CRAFTED_ITEMS]: [testItem({ id: carrotSoup.id })], + [categoryIds.UPGRADES]: [], // Пустая категория + }) }) }) }) diff --git a/src/components/SearchBar/SearchBar.js b/src/components/SearchBar/SearchBar.js index 0b82326f..acee5f7a 100644 --- a/src/components/SearchBar/SearchBar.js +++ b/src/components/SearchBar/SearchBar.js @@ -1,18 +1,28 @@ import React from 'react' import PropTypes from 'prop-types' +import { useDebounceCallback } from 'usehooks-ts' +import TextField from '@mui/material/TextField.js' import './SearchBar.sass' const SearchBar = ({ placeholder, onSearch }) => { + const debouncedSearch = useDebounceCallback(value => { + onSearch(value) + }, 300) + const handleInputChange = event => { - onSearch(event.target.value) + debouncedSearch(event.target.value) } return (
    -
    ) diff --git a/src/components/SearchBar/SearchBar.test.js b/src/components/SearchBar/SearchBar.test.js index 38edef58..b9e4e90c 100644 --- a/src/components/SearchBar/SearchBar.test.js +++ b/src/components/SearchBar/SearchBar.test.js @@ -1,5 +1,6 @@ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' // Правильный импорт import { describe, it, expect, vi } from 'vitest' import SearchBar from './SearchBar.js' @@ -19,14 +20,17 @@ describe('SearchBar Component', () => { expect(inputElement).toBeInTheDocument() }) - it('calls onSearch with correct value when typing', () => { + it('calls onSearch with correct value when typing', async () => { const onSearchMock = vi.fn() render() const inputElement = screen.getByPlaceholderText('Type here') - fireEvent.change(inputElement, { target: { value: 'test query' } }) + await userEvent.type(inputElement, 'test query') // Используем userEvent - expect(onSearchMock).toHaveBeenCalledTimes(1) - expect(onSearchMock).toHaveBeenCalledWith('test query') + // Проверяем debounce + await waitFor(() => { + expect(onSearchMock).toHaveBeenCalledTimes(1) + expect(onSearchMock).toHaveBeenCalledWith('test query') + }) }) })