Created
June 6, 2025 16:43
-
-
Save sunmeat/e3d0166a510d61606617a6df72a29f99 to your computer and use it in GitHub Desktop.
RTL + vitest example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
App.jsx: | |
import {useState, useEffect, useCallback, useMemo} from 'react'; | |
import {QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; | |
import Dexie from 'dexie'; // npm install dexie | |
import './App.css'; | |
const db = new Dexie('CartDatabase'); | |
db.version(1).stores({ | |
cart: 'id, title, price, image, quantity', | |
}); | |
const queryClient = new QueryClient({ | |
defaultOptions: { | |
queries: { | |
retry: 2, | |
staleTime: 5 * 60 * 1000, | |
}, | |
}, | |
}); | |
const truncateText = (text, maxLength) => { | |
if (!text) return ''; | |
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text; | |
}; | |
const formatPrice = (price) => { | |
return typeof price === 'number' ? price.toFixed(2) : '0.00'; | |
}; | |
const cartService = { | |
async getCart() { | |
try { | |
return await db.cart.toArray(); | |
} catch (error) { | |
console.error('Ошибка загрузки корзины:', error); | |
return []; | |
} | |
}, | |
async addItem(product) { | |
try { | |
const existingItem = await db.cart.get(product.id); | |
if (existingItem) { | |
const updatedItem = {...existingItem, quantity: existingItem.quantity + 1}; | |
await db.cart.put(updatedItem); | |
return updatedItem; | |
} else { | |
const newItem = { | |
id: product.id, | |
title: product.title, | |
price: product.price, | |
image: product.image, | |
quantity: 1, | |
}; | |
await db.cart.put(newItem); | |
return newItem; | |
} | |
} catch (error) { | |
console.error('Ошибка добавления в корзину:', error); | |
throw error; | |
} | |
}, | |
async updateQuantity(productId, quantity) { | |
try { | |
if (quantity <= 0) { | |
return this.removeItem(productId); | |
} | |
const item = await db.cart.get(productId); | |
if (item) { | |
const updatedItem = {...item, quantity}; | |
await db.cart.put(updatedItem); | |
return updatedItem; | |
} | |
} catch (error) { | |
console.error('Ошибка обновления количества:', error); | |
throw error; | |
} | |
}, | |
async removeItem(productId) { | |
try { | |
await db.cart.delete(productId); | |
return productId; | |
} catch (error) { | |
console.error('Ошибка удаления из корзины:', error); | |
throw error; | |
} | |
}, | |
async clearCart() { | |
try { | |
await db.cart.clear(); | |
} catch (error) { | |
console.error('Ошибка очистки корзины:', error); | |
throw error; | |
} | |
} | |
}; | |
function Notification({message, type, onClose}) { | |
useEffect(() => { | |
const timer = setTimeout(onClose, 3000); | |
return () => clearTimeout(timer); | |
}, [onClose]); | |
return ( | |
<div className={`notification notification-${type}`}> | |
{message} | |
<button onClick={onClose} className="notification-close">×</button> | |
</div> | |
); | |
} | |
function LoadingSpinner({size = 'medium'}) { | |
return <div className={`loading-spinner loading-${size}`}>Загрузка...</div>; | |
} | |
function ProductList() { | |
const [searchTerm, setSearchTerm] = useState(''); | |
const [notification, setNotification] = useState(null); | |
const queryClient = useQueryClient(); | |
const fetchProducts = async () => { | |
const response = await fetch('https://fakestoreapi.com/products?limit=20'); | |
if (!response.ok) throw new Error('Ошибка загрузки продуктов'); | |
const data = await response.json(); | |
return data.filter(product => | |
product && | |
product.id && | |
product.title && | |
typeof product.price === 'number' | |
); | |
}; | |
const {data: products = [], error, isLoading} = useQuery({ | |
queryKey: ['products'], | |
queryFn: fetchProducts, | |
}); | |
const addToCartMutation = useMutation({ | |
mutationFn: cartService.addItem, | |
onSuccess: (data) => { | |
queryClient.invalidateQueries({queryKey: ['cart']}); | |
setNotification({ | |
message: `${data.title} добавлен в корзину`, | |
type: 'success' | |
}); | |
}, | |
onError: (error) => { | |
setNotification({ | |
message: 'Ошибка при добавлении товара' + error, | |
type: 'error' | |
}); | |
} | |
}); | |
const filteredProducts = useMemo(() => { | |
if (!searchTerm) return products; | |
return products.filter(product => | |
product.title.toLowerCase().includes(searchTerm.toLowerCase()) || | |
product.category?.toLowerCase().includes(searchTerm.toLowerCase()) | |
); | |
}, [products, searchTerm]); | |
const handleAddToCart = useCallback((product) => { | |
addToCartMutation.mutate(product); | |
}, [addToCartMutation]); | |
if (isLoading) return <LoadingSpinner/>; | |
if (error) return <div className="error">Ошибка: {error.message}</div>; | |
return ( | |
<div className="product-list"> | |
{notification && ( | |
<Notification | |
message={notification.message} | |
type={notification.type} | |
onClose={() => setNotification(null)} | |
/> | |
)} | |
<div className="product-header"> | |
<h2>Наши товары ({filteredProducts.length})</h2> | |
<div className="search-container"> | |
<input | |
type="text" | |
placeholder="Поиск товаров..." | |
value={searchTerm} | |
onChange={(e) => setSearchTerm(e.target.value)} | |
className="search-input" | |
/> | |
</div> | |
</div> | |
<div className="products"> | |
{filteredProducts.map((product) => ( | |
<div key={product.id} className="product-card"> | |
<div className="product-image-container"> | |
<img | |
src={product.image} | |
alt={product.title} | |
className="product-image" | |
loading="lazy" | |
/> | |
</div> | |
<div className="product-info"> | |
<h3 title={product.title}> | |
{truncateText(product.title, 50)} | |
</h3> | |
<p className="product-category">{product.category}</p> | |
<p className="price">${formatPrice(product.price)}</p> | |
<button | |
onClick={() => handleAddToCart(product)} | |
className="add-to-cart" | |
disabled={addToCartMutation.isPending} | |
> | |
{addToCartMutation.isPending ? 'Добавление...' : 'Добавить в корзину'} | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
{filteredProducts.length === 0 && searchTerm && ( | |
<div className="no-results"> | |
<p>По запросу "{searchTerm}" ничего не найдено</p> | |
<button onClick={() => setSearchTerm('')} className="clear-search"> | |
Очистить поиск | |
</button> | |
</div> | |
)} | |
</div> | |
); | |
} | |
function Cart() { | |
const [notification, setNotification] = useState(null); | |
const queryClient = useQueryClient(); | |
const {data: cart = [], isLoading, error} = useQuery({ | |
queryKey: ['cart'], | |
queryFn: cartService.getCart, | |
}); | |
const updateQuantityMutation = useMutation({ | |
mutationFn: ({productId, quantity}) => cartService.updateQuantity(productId, quantity), | |
onSuccess: () => { | |
queryClient.invalidateQueries({queryKey: ['cart']}); | |
}, | |
onError: () => { | |
setNotification({ | |
message: 'Ошибка при обновлении количества', | |
type: 'error' | |
}); | |
} | |
}); | |
const removeItemMutation = useMutation({ | |
mutationFn: cartService.removeItem, | |
onSuccess: () => { | |
queryClient.invalidateQueries({queryKey: ['cart']}); | |
setNotification({ | |
message: 'Товар удален из корзины', | |
type: 'success' | |
}); | |
}, | |
onError: () => { | |
setNotification({ | |
message: 'Ошибка при удалении товара', | |
type: 'error' | |
}); | |
} | |
}); | |
const clearCartMutation = useMutation({ | |
mutationFn: cartService.clearCart, | |
onSuccess: () => { | |
queryClient.invalidateQueries({queryKey: ['cart']}); | |
setNotification({ | |
message: 'Корзина очищена', | |
type: 'success' | |
}); | |
}, | |
onError: () => { | |
setNotification({ | |
message: 'Ошибка при очистке корзины', | |
type: 'error' | |
}); | |
} | |
}); | |
const total = useMemo(() => { | |
return cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); | |
}, [cart]); | |
const totalItems = useMemo(() => { | |
return cart.reduce((sum, item) => sum + item.quantity, 0); | |
}, [cart]); | |
const handleQuantityChange = useCallback((productId, quantity) => { | |
updateQuantityMutation.mutate({productId, quantity}); | |
}, [updateQuantityMutation]); | |
const handleRemoveItem = useCallback((productId) => { | |
removeItemMutation.mutate(productId); | |
}, [removeItemMutation]); | |
const handleClearCart = useCallback(() => { | |
if (window.confirm('Вы уверены, что хотите очистить корзину?')) { | |
clearCartMutation.mutate(); | |
} | |
}, [clearCartMutation]); | |
if (isLoading) return <LoadingSpinner size="small"/>; | |
if (error) return <div className="error">Ошибка загрузки корзины</div>; | |
return ( | |
<div className="cart"> | |
{notification && ( | |
<Notification | |
message={notification.message} | |
type={notification.type} | |
onClose={() => setNotification(null)} | |
/> | |
)} | |
<div className="cart-header"> | |
<h2>Корзина ({totalItems} товаров)</h2> | |
{cart.length > 0 && ( | |
<button | |
onClick={handleClearCart} | |
className="clear-cart-btn" | |
disabled={clearCartMutation.isPending} | |
> | |
{clearCartMutation.isPending ? 'Очистка...' : 'Очистить корзину'} | |
</button> | |
)} | |
</div> | |
{cart.length === 0 ? ( | |
<div className="empty-cart"> | |
<p>Корзина пуста</p> | |
<p className="empty-cart-hint">Добавьте товары из каталога</p> | |
</div> | |
) : ( | |
<> | |
<div className="cart-items"> | |
{cart.map(item => ( | |
<div key={item.id} className="cart-item"> | |
<div className="cart-item-image"> | |
<img src={item.image} alt={item.title}/> | |
</div> | |
<div className="cart-item-info"> | |
<h4 title={item.title}> | |
{truncateText(item.title, 40)} | |
</h4> | |
<p className="item-price">${formatPrice(item.price)}</p> | |
</div> | |
<div className="cart-item-controls"> | |
<div className="quantity-controls"> | |
<button | |
onClick={() => handleQuantityChange(item.id, item.quantity - 1)} | |
disabled={updateQuantityMutation.isPending} | |
className="quantity-btn" | |
> | |
- | |
</button> | |
<span className="quantity">{item.quantity}</span> | |
<button | |
onClick={() => handleQuantityChange(item.id, item.quantity + 1)} | |
disabled={updateQuantityMutation.isPending} | |
className="quantity-btn" | |
> | |
+ | |
</button> | |
</div> | |
<div className="item-total"> | |
${formatPrice(item.price * item.quantity)} | |
</div> | |
<button | |
className="remove-item-btn" | |
onClick={() => handleRemoveItem(item.id)} | |
disabled={removeItemMutation.isPending} | |
title="Удалить товар" | |
> | |
🗑️ | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
<div className="cart-summary"> | |
<div className="total"> | |
<strong>Итого: ${formatPrice(total)}</strong> | |
</div> | |
<button className="checkout-btn"> | |
Оформить заказ | |
</button> | |
</div> | |
</> | |
)} | |
</div> | |
); | |
} | |
function AppContent() { | |
return ( | |
<div className="app"> | |
<header className="app-header"> | |
<h1>🛒 Интернет-магазин ReactExpress</h1> | |
<p className="app-subtitle">Качественные товары по доступным ценам</p> | |
</header> | |
<main className="app-main"> | |
<ProductList/> | |
<Cart/> | |
</main> | |
</div> | |
); | |
} | |
function App() { | |
return ( | |
<QueryClientProvider client={queryClient}> | |
<AppContent/> | |
</QueryClientProvider> | |
); | |
} | |
export default App; | |
======================================================================================================= | |
App.css: | |
.app { | |
max-width: 1400px; | |
margin: 0 auto; | |
padding: 20px; | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
min-height: 100vh; | |
} | |
.app-header { | |
text-align: center; | |
margin-bottom: 40px; | |
padding: 30px 0; | |
background: rgba(255, 255, 255, 0.1); | |
border-radius: 20px; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255, 255, 255, 0.2); | |
} | |
.app-header h1 { | |
color: #ffffff; | |
font-size: 3em; | |
font-weight: 700; | |
margin: 0 0 10px 0; | |
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); | |
letter-spacing: -1px; | |
} | |
.app-subtitle { | |
color: rgba(255, 255, 255, 0.9); | |
font-size: 1.2em; | |
margin: 0; | |
font-weight: 300; | |
} | |
.app-main { | |
display: grid; | |
grid-template-columns: 2fr 1fr; | |
gap: 30px; | |
align-items: start; | |
} | |
@media (max-width: 1024px) { | |
.app-main { | |
grid-template-columns: 1fr; | |
gap: 20px; | |
} | |
} | |
.product-list { | |
background: rgba(255, 255, 255, 0.95); | |
border-radius: 20px; | |
padding: 30px; | |
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); | |
backdrop-filter: blur(10px); | |
} | |
.product-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 30px; | |
flex-wrap: wrap; | |
gap: 20px; | |
} | |
.product-header h2 { | |
color: #2c3e50; | |
margin: 0; | |
font-size: 2em; | |
font-weight: 600; | |
} | |
.search-container { | |
flex: 1; | |
max-width: 300px; | |
min-width: 200px; | |
} | |
.search-input { | |
width: 100%; | |
padding: 12px 10px; | |
border: 2px solid #e0e0e0; | |
border-radius: 25px; | |
font-size: 1em; | |
transition: all 0.3s ease; | |
background: #f8f9fa; | |
} | |
.search-input:focus { | |
outline: none; | |
border-color: #667eea; | |
background: #ffffff; | |
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
} | |
.products { | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
gap: 25px; | |
} | |
.product-card { | |
background: #ffffff; | |
border-radius: 16px; | |
padding: 20px; | |
text-align: center; | |
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08); | |
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
border: 1px solid rgba(0, 0, 0, 0.05); | |
display: flex; | |
flex-direction: column; | |
height: 420px; | |
} | |
.product-card:hover { | |
transform: translateY(-8px) scale(1.02); | |
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); | |
} | |
.product-image-container { | |
height: 180px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
margin-bottom: 15px; | |
background: #f8f9fa; | |
border-radius: 12px; | |
overflow: hidden; | |
} | |
.product-image { | |
max-width: 100%; | |
max-height: 100%; | |
object-fit: contain; | |
transition: transform 0.3s ease; | |
} | |
.product-card:hover .product-image { | |
transform: scale(1.05); | |
} | |
.product-info { | |
flex: 1; | |
display: flex; | |
flex-direction: column; | |
justify-content: space-between; | |
} | |
.product-info h3 { | |
color: #2c3e50; | |
margin: 0 0 8px 0; | |
font-size: 1.1em; | |
font-weight: 600; | |
line-height: 1.3; | |
min-height: 2.6em; | |
} | |
.product-category { | |
color: #7f8c8d; | |
font-size: 0.9em; | |
margin: 0 0 10px 0; | |
text-transform: capitalize; | |
font-weight: 500; | |
} | |
.price { | |
color: #e74c3c; | |
font-weight: 700; | |
font-size: 1.4em; | |
margin: 10px 0 15px 0; | |
} | |
.add-to-cart { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
border: none; | |
padding: 12px 24px; | |
cursor: pointer; | |
border-radius: 25px; | |
font-size: 1em; | |
font-weight: 600; | |
transition: all 0.3s ease; | |
text-transform: uppercase; | |
letter-spacing: 0.5px; | |
} | |
.add-to-cart:hover:not(:disabled) { | |
transform: translateY(-2px); | |
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); | |
} | |
.add-to-cart:disabled { | |
opacity: 0.7; | |
cursor: not-allowed; | |
} | |
.no-results { | |
text-align: center; | |
padding: 60px 20px; | |
color: #7f8c8d; | |
} | |
.no-results p { | |
font-size: 1.2em; | |
margin-bottom: 20px; | |
} | |
.clear-search { | |
background: #3498db; | |
color: white; | |
border: none; | |
padding: 10px 20px; | |
border-radius: 20px; | |
cursor: pointer; | |
transition: background 0.3s ease; | |
} | |
.clear-search:hover { | |
background: #2980b9; | |
} | |
.cart { | |
background: rgba(255, 255, 255, 0.95); | |
border-radius: 20px; | |
padding: 30px; | |
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); | |
backdrop-filter: blur(10px); | |
position: sticky; | |
top: 20px; | |
max-height: calc(100vh - 40px); | |
overflow-y: auto; | |
} | |
.cart-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 25px; | |
flex-wrap: wrap; | |
gap: 15px; | |
} | |
.cart-header h2 { | |
color: #2c3e50; | |
margin: 0; | |
font-size: 1.8em; | |
font-weight: 600; | |
} | |
.clear-cart-btn { | |
background: #e74c3c; | |
color: white; | |
border: none; | |
padding: 8px 16px; | |
border-radius: 20px; | |
cursor: pointer; | |
font-size: 0.9em; | |
transition: all 0.3s ease; | |
} | |
.clear-cart-btn:hover:not(:disabled) { | |
background: #c0392b; | |
transform: translateY(-1px); | |
} | |
.clear-cart-btn:disabled { | |
opacity: 0.7; | |
cursor: not-allowed; | |
} | |
.empty-cart { | |
text-align: center; | |
padding: 40px 20px; | |
color: #7f8c8d; | |
} | |
.empty-cart p { | |
margin: 0 0 10px 0; | |
font-size: 1.1em; | |
} | |
.empty-cart-hint { | |
font-size: 0.9em; | |
opacity: 0.7; | |
} | |
.cart-items { | |
margin-bottom: 25px; | |
} | |
.cart-item { | |
display: grid; | |
grid-template-columns: 60px 1fr auto; | |
gap: 15px; | |
align-items: center; | |
padding: 15px 0; | |
border-bottom: 1px solid #ecf0f1; | |
} | |
.cart-item:last-child { | |
border-bottom: none; | |
} | |
.cart-item-image { | |
width: 60px; | |
height: 60px; | |
border-radius: 8px; | |
overflow: hidden; | |
background: #f8f9fa; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.cart-item-image img { | |
width: 100%; | |
height: 100%; | |
object-fit: contain; | |
} | |
.cart-item-info h4 { | |
margin: 0 0 5px 0; | |
color: #2c3e50; | |
font-size: 0.95em; | |
font-weight: 600; | |
line-height: 1.3; | |
} | |
.item-price { | |
margin: 0; | |
color: #7f8c8d; | |
font-size: 0.9em; | |
} | |
.cart-item-controls { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
gap: 8px; | |
} | |
.quantity-controls { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
background: #f8f9fa; | |
border-radius: 20px; | |
padding: 4px; | |
} | |
.quantity-btn { | |
width: 28px; | |
height: 28px; | |
border: none; | |
background: #ffffff; | |
border-radius: 50%; | |
cursor: pointer; | |
font-weight: bold; | |
color: #667eea; | |
transition: all 0.2s ease; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.quantity-btn:hover:not(:disabled) { | |
background: #667eea; | |
color: white; | |
transform: scale(1.1); | |
} | |
.quantity-btn:disabled { | |
opacity: 0.5; | |
cursor: not-allowed; | |
} | |
.quantity { | |
min-width: 30px; | |
text-align: center; | |
font-weight: 600; | |
color: #2c3e50; | |
} | |
.item-total { | |
font-weight: 700; | |
color: #e74c3c; | |
font-size: 0.9em; | |
} | |
.remove-item-btn { | |
background: none; | |
border: none; | |
cursor: pointer; | |
font-size: 1.2em; | |
padding: 4px; | |
border-radius: 4px; | |
transition: all 0.2s ease; | |
opacity: 0.6; | |
} | |
.remove-item-btn:hover:not(:disabled) { | |
opacity: 1; | |
transform: scale(1.2); | |
background: rgba(231, 76, 60, 0.1); | |
} | |
.remove-item-btn:disabled { | |
opacity: 0.3; | |
cursor: not-allowed; | |
} | |
.cart-summary { | |
border-top: 2px solid #ecf0f1; | |
padding-top: 20px; | |
display: flex; | |
flex-direction: column; | |
gap: 15px; | |
} | |
.total { | |
font-size: 1.4em; | |
font-weight: 700; | |
color: #2c3e50; | |
text-align: right; | |
} | |
.checkout-btn { | |
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); | |
color: white; | |
border: none; | |
padding: 15px 30px; | |
border-radius: 25px; | |
font-size: 1.1em; | |
font-weight: 600; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
text-transform: uppercase; | |
letter-spacing: 1px; | |
} | |
.checkout-btn:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.4); | |
} | |
.notification { | |
position: fixed; | |
top: 20px; | |
right: 20px; | |
padding: 15px 20px; | |
border-radius: 10px; | |
color: white; | |
font-weight: 600; | |
z-index: 1000; | |
animation: slideIn 0.3s ease; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
gap: 15px; | |
min-width: 300px; | |
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); | |
} | |
.notification-success { | |
background: linear-gradient(135deg, #2ecc71, #27ae60); | |
} | |
.notification-error { | |
background: linear-gradient(135deg, #e74c3c, #c0392b); | |
} | |
.notification-close { | |
background: none; | |
border: none; | |
color: white; | |
font-size: 1.2em; | |
cursor: pointer; | |
padding: 0; | |
width: 20px; | |
height: 20px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
border-radius: 50%; | |
transition: background 0.2s ease; | |
} | |
.notification-close:hover { | |
background: rgba(255, 255, 255, 0.2); | |
} | |
@keyframes slideIn { | |
from { | |
transform: translateX(100%); | |
opacity: 0; | |
} | |
to { | |
transform: translateX(0); | |
opacity: 1; | |
} | |
} | |
.loading-spinner { | |
text-align: center; | |
padding: 40px 20px; | |
color: #7f8c8d; | |
font-size: 1.1em; | |
} | |
.loading-medium { | |
padding: 60px 20px; | |
font-size: 1.3em; | |
} | |
.loading-small { | |
padding: 20px; | |
font-size: 1em; | |
} | |
.error { | |
text-align: center; | |
padding: 40px 20px; | |
color: #e74c3c; | |
font-size: 1.1em; | |
background: rgba(231, 76, 60, 0.1); | |
border-radius: 10px; | |
border: 1px solid rgba(231, 76, 60, 0.2); | |
} | |
@media (max-width: 768px) { | |
.app { | |
padding: 15px; | |
} | |
.app-header h1 { | |
font-size: 2.2em; | |
} | |
.app-subtitle { | |
font-size: 1em; | |
} | |
.product-list, | |
.cart { | |
padding: 20px; | |
} | |
.product-header { | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.search-container { | |
max-width: none; | |
} | |
.products { | |
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
gap: 20px; | |
} | |
.product-card { | |
height: auto; | |
min-height: 380px; | |
} | |
.cart { | |
position: static; | |
max-height: none; | |
} | |
.cart-header { | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.cart-item { | |
grid-template-columns: 50px 1fr; | |
gap: 10px; | |
} | |
.cart-item-controls { | |
grid-column: 1 / -1; | |
flex-direction: row; | |
justify-content: space-between; | |
margin-top: 10px; | |
} | |
.notification { | |
left: 15px; | |
right: 15px; | |
min-width: auto; | |
} | |
} | |
@media (max-width: 480px) { | |
.app-header h1 { | |
font-size: 1.8em; | |
} | |
.products { | |
grid-template-columns: 1fr; | |
} | |
.product-header h2 { | |
font-size: 1.5em; | |
} | |
.cart-header h2 { | |
font-size: 1.5em; | |
} | |
} | |
======================================================================================================= | |
App.test.jsx: | |
import {act, render, waitFor, screen, fireEvent} from '@testing-library/react'; // импортируем функции для тестирования компонентов react | |
import {vi, beforeEach, afterEach, describe, it, expect} from 'vitest'; // импортируем функции vitest для написания тестов и моков | |
import React from 'react'; // импортируем react для использования jsx | |
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; // импортируем react-query для управления запросами и кэшем | |
let cartData = []; // создаем массив для хранения данных корзины | |
let dbCallbacks = []; // создаем массив для хранения колбэков обновления компонентов | |
function createMockDb() { // определяем функцию для создания мока базы данных | |
const notifyCallbacks = () => { // создаем функцию для вызова всех колбэков | |
dbCallbacks.forEach(callback => { // перебираем массив колбэков | |
if (typeof callback === 'function') callback(); // вызываем каждый колбэк, если он является функцией | |
}); | |
}; | |
return { // возвращаем объект мока базы данных | |
version: vi.fn().mockReturnThis(), // мокаем метод version, возвращающий сам объект | |
stores: vi.fn().mockReturnThis(), // мокаем метод stores, возвращающий сам объект | |
cart: { // создаем объект для работы с корзиной | |
toArray: vi.fn(() => Promise.resolve([...cartData])), // мокаем метод toArray, возвращающий копию данных корзины | |
get: vi.fn(id => Promise.resolve(cartData.find(item => item.id === id) || null)), // мокаем метод get, возвращающий элемент по id | |
put: vi.fn(item => { // мокаем метод put для добавления или обновления элемента | |
const idx = cartData.findIndex(i => i.id === item.id); // ищем индекс элемента в корзине | |
if (idx !== -1) cartData[idx] = {...item}; // обновляем существующий элемент | |
else cartData.push({...item}); // добавляем новый элемент | |
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой | |
return Promise.resolve(); // возвращаем промис | |
}), | |
delete: vi.fn(id => { // мокаем метод delete для удаления элемента | |
cartData = cartData.filter(i => i.id !== id); // удаляем элемент из корзины | |
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой | |
return Promise.resolve(); // возвращаем промис | |
}), | |
clear: vi.fn(() => { // мокаем метод clear для очистки корзины | |
cartData = []; // очищаем массив корзины | |
setTimeout(notifyCallbacks, 10); // вызываем колбэки с задержкой | |
return Promise.resolve(); // возвращаем промис | |
}), | |
}, | |
subscribe: vi.fn(cb => { // мокаем метод subscribe для подписки на изменения | |
dbCallbacks.push(cb); // добавляем колбэк в массив | |
return () => { // возвращаем функцию для отписки | |
dbCallbacks = dbCallbacks.filter(x => x !== cb); // удаляем колбэк из массива | |
}; | |
}), | |
}; | |
} | |
let mockDb = vi.hoisted(() => createMockDb()); // создаем мок базы данных с использованием hoisted для корректной инициализации | |
vi.mock('dexie', () => ({ // мокаем библиотеку dexie | |
default: vi.fn().mockImplementation(() => mockDb) // возвращаем замоканную базу данных | |
})); | |
const renderWithProviders = () => { // определяем функцию для рендеринга компонента с провайдерами | |
const queryClient = new QueryClient({ // создаем новый клиент react-query | |
defaultOptions: { // задаем стандартные настройки | |
queries: { // настройки для запросов | |
retry: 0, // отключаем повторные попытки запросов | |
staleTime: 0, // отключаем время устаревания данных | |
gcTime: 0, // отключаем время жизни кэша | |
refetchOnWindowFocus: false, // отключаем повторный запрос при фокусе окна | |
refetchOnMount: true, // включаем повторный запрос при монтировании | |
}, | |
mutations: { // настройки для мутаций | |
retry: 0, // отключаем повторные попытки мутаций | |
}, | |
}, | |
}); | |
return render( // рендерим компонент | |
<QueryClientProvider client={queryClient}> // оборачиваем в провайдер react-query | |
<App/> // рендерим компонент App | |
</QueryClientProvider> | |
); | |
}; | |
beforeEach(() => { // задаем действия перед каждым тестом | |
vi.resetAllMocks(); // сбрасываем все моки | |
cartData = []; // очищаем массив корзины | |
dbCallbacks = []; // очищаем массив колбэков | |
mockDb = createMockDb(); // создаем новый мок базы данных | |
globalThis.indexedDB = { // мокаем indexedDB | |
open: vi.fn(), // мокаем метод open | |
deleteDatabase: vi.fn(), // мокаем метод deleteDatabase | |
cmp: vi.fn() // мокаем метод cmp | |
}; | |
globalThis.fetch = vi.fn().mockResolvedValue({ // мокаем fetch для получения продуктов | |
ok: true, // задаем успешный статус ответа | |
json: () => Promise.resolve([ // возвращаем тестовые данные | |
{ | |
id: 1, // id первого продукта | |
title: 'тестовая шаурма', // название первого продукта | |
price: 10.99, // цена первого продукта | |
image: 'https://example.com/image1.jpg', // изображение первого продукта | |
category: 'electronics' // категория первого продукта | |
}, | |
{ | |
id: 2, // id второго продукта | |
title: 'тестовый хотдог', // название второго продукта | |
price: 15.50, // цена второго продукта | |
image: 'https://example.com/image2.jpg', // изображение второго продукта | |
category: 'clothing' // категория второго продукта | |
}, | |
]), | |
}); | |
}); | |
afterEach(() => { // задаем действия после каждого теста | |
vi.clearAllMocks(); // очищаем все моки | |
vi.restoreAllMocks(); // восстанавливаем все моки | |
cartData = []; // очищаем массив корзины | |
dbCallbacks = []; // очищаем массив колбэков | |
}); | |
import App from './App'; // импортируем компонент App после настройки моков | |
describe('Компонент App', () => { // описываем группу тестов для компонента App | |
it('рендерится без ошибок', async () => { // тестируем рендеринг компонента | |
await act(async () => { // оборачиваем рендеринг в act для корректного обновления | |
renderWithProviders(); // рендерим компонент с провайдерами | |
}); | |
await waitFor(() => { // ждем, пока элемент появится | |
expect(screen.getByText(/Интернет-магазин ReactExpress/)).toBeInTheDocument(); // проверяем наличие заголовка | |
}, {timeout: 5000}); // задаем таймаут 5 секунд | |
}); | |
it('отображает заголовок приложения', async () => { // тестируем отображение заголовка | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
expect(screen.getByText('🛒 Интернет-магазин ReactExpress')).toBeInTheDocument(); // проверяем наличие заголовка с эмодзи | |
expect(screen.getByText('Качественные товары по доступным ценам')).toBeInTheDocument(); // проверяем наличие подзаголовка | |
}); | |
it('не отображает кнопку оформления заказа когда корзина пуста', async () => { // тестируем отсутствие кнопки при пустой корзине | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
expect(screen.queryByText('Оформить заказ')).not.toBeInTheDocument(); // проверяем отсутствие кнопки оформления | |
}); | |
it('отображает список товаров после загрузки', async () => { // тестируем отображение списка товаров | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем отображения количества товаров | |
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем наличие заголовка с количеством | |
}); | |
expect(await screen.findByText('тестовая шаурма')).toBeInTheDocument(); // проверяем наличие первого товара | |
expect(await screen.findByText('тестовый хотдог')).toBeInTheDocument(); // проверяем наличие второго товара | |
}); | |
it('отображает цены товаров', async () => { // тестируем отображение цен | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
expect(await screen.findByText('$10.99')).toBeInTheDocument(); // проверяем отображение цены первого товара | |
expect(await screen.findByText('$15.50')).toBeInTheDocument(); // проверяем отображение цены второго товара | |
}); | |
it('отображает категории товаров', async () => { // тестируем отображение категорий | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
expect(await screen.findByText('electronics')).toBeInTheDocument(); // проверяем отображение категории первого товара | |
expect(await screen.findByText('clothing')).toBeInTheDocument(); // проверяем отображение категории второго товара | |
}); | |
it('отображает кнопки добавления в корзину', async () => { // тестируем наличие кнопок добавления | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
const addButtons = await screen.findAllByText('Добавить в корзину'); // находим все кнопки добавления | |
expect(addButtons).toHaveLength(2); // проверяем, что кнопок две | |
}); | |
it('работает поиск товаров', async () => { // тестируем функциональность поиска | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
const searchInput = await screen.findByPlaceholderText('Поиск товаров...'); // находим поле ввода поиска | |
await act(async () => { // оборачиваем изменение поля в act | |
fireEvent.change(searchInput, {target: {value: 'тестовая шаурма'}}); // вводим запрос в поле поиска | |
}); | |
await waitFor(() => { // ждем обновления списка товаров | |
expect(screen.getByText(/Наши товары \(1\)/)).toBeInTheDocument(); // проверяем, что отображается один товар | |
}); | |
expect(screen.getByText('тестовая шаурма')).toBeInTheDocument(); // проверяем наличие искомого товара | |
expect(screen.queryByText('тестовый хотдог')).not.toBeInTheDocument(); // проверяем отсутствие другого товара | |
}); | |
it('показывает сообщение когда поиск не дал результатов', async () => { // тестируем сообщение при пустом поиске | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
const searchInput = await screen.findByPlaceholderText('Поиск товаров...'); // находим поле ввода поиска | |
await act(async () => { // оборачиваем изменение поля в act | |
fireEvent.change(searchInput, {target: {value: 'Non-existent Product'}}); // вводим несуществующий запрос | |
}); | |
await waitFor(() => { // ждем отображения сообщения | |
expect(screen.getByText(/По запросу "Non-existent Product" ничего не найдено/)).toBeInTheDocument(); // проверяем наличие сообщения | |
}); | |
const clearButton = screen.getByText('Очистить поиск'); // находим кнопку очистки поиска | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(clearButton); // кликаем по кнопке очистки | |
}); | |
await waitFor(() => { // ждем восстановления списка товаров | |
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение всех товаров | |
}); | |
}); | |
it('отображает пустую корзину', async () => { // тестируем отображение пустой корзины | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем заголовок пустой корзины | |
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине | |
expect(screen.getByText('Добавьте товары из каталога')).toBeInTheDocument(); // проверяем подсказку | |
}); | |
it('добавляет товар в корзину', async () => { // тестируем добавление товара в корзину | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем загрузки товаров | |
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение товаров | |
}); | |
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления | |
name: /Добавить в корзину/i, | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[0]); // кликаем по первой кнопке | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем обновление корзины | |
}, {timeout: 2000}); | |
expect(screen.getByRole('heading', {level: 4, name: 'тестовая шаурма'})).toBeInTheDocument(); // проверяем наличие товара в корзине | |
expect(screen.queryByText('Корзина пуста')).not.toBeInTheDocument(); // проверяем отсутствие сообщения о пустой корзине | |
}); | |
it('удаляет товар из корзины', async () => { // тестируем удаление товара из корзины | |
console.log('cartData при входе в тест', cartData); // логируем начальное состояние корзины | |
cartData = [{ // заполняем корзину тестовым товаром | |
id: 1, | |
title: 'тестовая шаурма', | |
price: 10.99, | |
image: 'https://example.com/image1.jpg', | |
quantity: 1 | |
}]; | |
console.log('cartData перед рендером', cartData); // логируем состояние корзины перед рендерингом | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
console.log('cartData после рендера', cartData); // логируем состояние корзины после рендеринга | |
await waitFor(() => { // ждем отображения корзины | |
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара | |
}, {timeout: 2000}); | |
const removeButton = screen.getByTitle('Удалить товар'); // находим кнопку удаления | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(removeButton); // кликаем по кнопке удаления | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста | |
}, {timeout: 2000}); | |
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине | |
}); | |
it('очищает всю корзину', async () => { // тестируем очистку корзины | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем отображения корзины | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста | |
}); | |
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления | |
name: /Добавить в корзину/i, | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[0]); // кликаем по первой кнопке | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[1]); // кликаем по второй кнопке | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(2 товаров\)/)).toBeInTheDocument(); // проверяем наличие двух товаров | |
}, {timeout: 2000}); | |
const clearButton = screen.getByText('Очистить корзину'); // находим кнопку очистки корзины | |
vi.spyOn(window, 'confirm').mockImplementation(() => true); // мокаем confirm, чтобы возвращал true | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(clearButton); // кликаем по кнопке очистки | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста | |
}, {timeout: 2000}); | |
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине | |
}); | |
it('правильно рассчитывает общую стоимость', async () => { // тестируем расчет общей стоимости | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем отображения корзины | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста | |
}); | |
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления | |
name: /Добавить в корзину/i, | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[0]); // кликаем по первой кнопке | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[1]); // кликаем по второй кнопке | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[1]); // кликаем по второй кнопке еще раз | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(3 товаров\)/)).toBeInTheDocument(); // проверяем наличие трех товаров | |
}, {timeout: 2000}); | |
await waitFor(() => { // ждем отображения итоговой суммы | |
expect(screen.getByText('Итого: $41.99')).toBeInTheDocument(); // проверяем правильность суммы | |
}); | |
}); | |
it('показывает уведомление при добавлении товара', async () => { // тестируем уведомление при добавлении | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем загрузки товаров | |
expect(screen.getByText(/Наши товары \(2\)/)).toBeInTheDocument(); // проверяем отображение товаров | |
}); | |
const addButtons = await screen.findAllByText('Добавить в корзину'); // находим кнопки добавления | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[0]); // кликаем по первой кнопке | |
}); | |
await waitFor(() => { // ждем появления уведомления | |
expect(screen.getByText(/тестовая шаурма добавлен в корзину/)).toBeInTheDocument(); // проверяем уведомление | |
}); | |
}); | |
it('отображает кнопку оформления заказа когда корзина не пуста', async () => { // тестируем кнопку оформления при непустой корзине | |
cartData = [{ // заполняем корзину тестовым товаром | |
id: 1, | |
title: 'тестовая шаурма', | |
price: 10.99, | |
image: 'https://example.com/image1.jpg', | |
quantity: 1 | |
}]; | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем появления кнопки | |
expect(screen.getByText('Оформить заказ')).toBeInTheDocument(); // проверяем наличие кнопки оформления | |
}); | |
}); | |
it('удаляет товар при уменьшении количества до 0', async () => { // тестируем удаление товара при уменьшении количества | |
cartData = [{ // заполняем корзину тестовым товаром | |
id: 1, | |
title: 'тестовая шаурма', | |
price: 10.99, | |
image: 'https://example.com/image1.jpg', | |
quantity: 1 | |
}]; | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
await waitFor(() => { // ждем отображения корзины | |
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара | |
}); | |
const minusButton = screen.getByRole('button', {name: '-'}); // находим кнопку уменьшения количества | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(minusButton); // кликаем по кнопке уменьшения | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(0 товаров\)/)).toBeInTheDocument(); // проверяем, что корзина пуста | |
}, {timeout: 2000}); | |
expect(screen.getByText('Корзина пуста')).toBeInTheDocument(); // проверяем сообщение о пустой корзине | |
}); | |
it('обновляет количество товара в корзине', async () => { // тестируем обновление количества товара | |
await act(async () => { // оборачиваем рендеринг в act | |
renderWithProviders(); // рендерим компонент | |
}); | |
const addButtons = await screen.findAllByRole('button', { // находим кнопки добавления | |
name: /Добавить в корзину/i, | |
}); | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(addButtons[0]); // кликаем по первой кнопке | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(1 товаров\)/)).toBeInTheDocument(); // проверяем наличие одного товара | |
}, {timeout: 2000}); | |
const plusButton = screen.getByRole('button', {name: '+'}); // находим кнопку увеличения количества | |
await act(async () => { // оборачиваем клик в act | |
fireEvent.click(plusButton); // кликаем по кнопке увеличения | |
}); | |
await waitFor(() => { // ждем обновления корзины | |
expect(screen.getByText(/Корзина \(2 товаров\)/)).toBeInTheDocument(); // проверяем наличие двух товаров | |
}, {timeout: 2000}); | |
expect(screen.getByText('2')).toBeInTheDocument(); // проверяем отображение количества | |
}); | |
}); | |
============================================================================================================================= | |
setupTests.js: | |
import '@testing-library/jest-dom' | |
============================================================================================================================= | |
vite.config.js: | |
import { defineConfig } from 'vite' | |
import react from '@vitejs/plugin-react' | |
export default defineConfig({ | |
plugins: [react()], | |
test: { | |
environment: 'jsdom', | |
globals: true, | |
setupFiles: './src/setupTests.js' | |
}, | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment