diff --git a/.gitignore b/.gitignore index 107dd257..ab7cc22d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ dist-ssr *.sw? .cursor docs +coverage *.pem diff --git a/package.json b/package.json index eada6cba..70c06411 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "reactjs-template", + "name": "dreamteam-concierge-mini-app", "private": true, - "version": "0.0.1", + "version": "1.0.1", "type": "module", "homepage": "https://caesai.github.io/dm_front/", "scripts": { diff --git a/src/__mocks__/localStorage.mock.ts b/src/__mocks__/localStorage.mock.ts new file mode 100644 index 00000000..1cc54888 --- /dev/null +++ b/src/__mocks__/localStorage.mock.ts @@ -0,0 +1,33 @@ +/** + * @fileoverview Мок для localStorage для использования в тестах. + * + * Предоставляет простую реализацию localStorage с возможностью очистки + * и перехвата вызовов для тестирования. + */ + +export const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +/** + * Устанавливает мок localStorage в window для тестов + */ +export const setupLocalStorageMock = () => { + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); +}; diff --git a/src/__tests__/booking/README.md b/src/__tests__/booking/README.md index e15b2179..514c3388 100644 --- a/src/__tests__/booking/README.md +++ b/src/__tests__/booking/README.md @@ -114,7 +114,7 @@ src/__tests__/ - Временные слоты - Контактные данные - Способ подтверждения -- Кнопка "Забронировать стол" +- Кнопка "Забронировать" - Создание бронирования (с event_id) - Редирект на онбординг - Ошибки API diff --git a/src/__tests__/events/EventBookingPage.test.tsx b/src/__tests__/events/EventBookingPage.test.tsx index c8a4343c..2937339f 100644 --- a/src/__tests__/events/EventBookingPage.test.tsx +++ b/src/__tests__/events/EventBookingPage.test.tsx @@ -107,6 +107,16 @@ jest.mock('@/api/certificates.api.ts', () => ({ APIPostCertificateClaim: jest.fn(() => Promise.resolve({})), })); +/** + * Мок useDataLoader. EventBookingPage вызывает loadEvents при перезагрузке + * (когда events пуст). В тестах данные задаются через атомы, мок предотвращает + * вызов APIGetEventsList. + */ +const mockLoadEvents = jest.fn(); +jest.mock('@/hooks/useDataLoader.ts', () => ({ + useDataLoader: () => ({ loadEvents: mockLoadEvents }), +})); + /** * Мок Telegram WebApp объекта. * Необходим для работы компонентов, использующих Telegram API. @@ -225,6 +235,7 @@ describe('EventBookingPage', () => { > } /> + Events List} /> @@ -516,17 +527,17 @@ describe('EventBookingPage', () => { // ============================================ /** - * Тесты кнопки "Забронировать стол". + * Тесты кнопки "Забронировать". */ describe('Кнопка бронирования', () => { /** * Проверяет наличие кнопки бронирования. */ - test('должен отображать кнопку "Забронировать стол"', async () => { + test('должен отображать кнопку "Забронировать"', async () => { renderComponent(); await waitFor(() => { - expect(screen.getByText('Забронировать стол')).toBeInTheDocument(); + expect(screen.getByText('Забронировать')).toBeInTheDocument(); }); }); @@ -538,7 +549,7 @@ describe('EventBookingPage', () => { renderComponent(mockUserData, mockEventsList, String(freeEvent.id), 0, 0); await waitFor(() => { - const button = screen.getByText('Забронировать стол').closest('button'); + const button = screen.getByText('Забронировать').closest('button'); expect(button).toBeDisabled(); }); }); @@ -570,7 +581,7 @@ describe('EventBookingPage', () => { }); // Нажимаем кнопку бронирования - const bookButton = screen.getByText('Забронировать стол'); + const bookButton = screen.getByText('Забронировать'); await act(async () => { fireEvent.click(bookButton); @@ -620,7 +631,7 @@ describe('EventBookingPage', () => { await new Promise(resolve => setTimeout(resolve, 100)); }); - const bookButton = screen.getByText('Забронировать стол'); + const bookButton = screen.getByText('Забронировать'); await act(async () => { fireEvent.click(bookButton); @@ -662,7 +673,7 @@ describe('EventBookingPage', () => { await new Promise(resolve => setTimeout(resolve, 100)); }); - const bookButton = screen.getByText('Забронировать стол'); + const bookButton = screen.getByText('Забронировать'); await act(async () => { fireEvent.click(bookButton); @@ -687,29 +698,48 @@ describe('EventBookingPage', () => { /** * Тесты поведения при отсутствии данных мероприятия. + * При перезагрузке страницы или неверном eventId данные восстанавливаются через API. + * Если мероприятие не найдено после загрузки — редирект на /events. */ describe('Отсутствие мероприятия', () => { /** - * Проверяет что страница не падает при отсутствии мероприятия в списке. + * Проверяет редирект на /events при несуществующем eventId. + * Мероприятие не найдено в списке → редирект на список мероприятий. */ - test('должен корректно обрабатывать отсутствие мероприятия', async () => { + test('должен перенаправлять на /events при отсутствии мероприятия в списке', async () => { renderComponent(mockUserData, mockEventsList, '99999', 0, 0); - // Страница должна отрендериться без ошибок await waitFor(() => { - expect(screen.getByText('Забронировать стол')).toBeInTheDocument(); + expect(screen.getByTestId('events-list-page')).toBeInTheDocument(); + expect(screen.queryByText('Забронировать')).not.toBeInTheDocument(); }); }); /** - * Проверяет что страница корректно обрабатывает пустой список мероприятий. + * Проверяет редирект на /events при пустом списке мероприятий. + * События загружены ([]), но мероприятие не найдено → редирект. */ - test('должен корректно обрабатывать пустой список мероприятий', async () => { + test('должен перенаправлять на /events при пустом списке мероприятий', async () => { renderComponent(mockUserData, [], String(freeEvent.id), 0, 0); await waitFor(() => { - expect(screen.getByText('Забронировать стол')).toBeInTheDocument(); + expect(screen.getByTestId('events-list-page')).toBeInTheDocument(); + expect(screen.queryByText('Забронировать')).not.toBeInTheDocument(); + }); + }); + + /** + * Проверяет отображение скелетона при events === null (перезагрузка страницы). + * Вызывается loadEvents; пока данные не загружены — скелетон. + */ + test('должен показывать скелетон при загрузке данных (events === null)', async () => { + renderComponent(mockUserData, null, String(freeEvent.id), 0, 0); + + await waitFor(() => { + expect(screen.getAllByTestId('placeholder-block').length).toBeGreaterThan(0); + expect(screen.queryByText('Забронировать')).not.toBeInTheDocument(); }); + expect(mockLoadEvents).toHaveBeenCalled(); }); }); @@ -753,7 +783,7 @@ describe('EventBookingPage', () => { await new Promise(resolve => setTimeout(resolve, 100)); }); - const bookButton = screen.getByText('Забронировать стол'); + const bookButton = screen.getByText('Забронировать'); await act(async () => { fireEvent.click(bookButton); diff --git a/src/__tests__/hooks/useBanquetForm.test.tsx b/src/__tests__/hooks/useBanquetForm.test.tsx new file mode 100644 index 00000000..106d2fa1 --- /dev/null +++ b/src/__tests__/hooks/useBanquetForm.test.tsx @@ -0,0 +1,499 @@ +/** + * @fileoverview Тесты для хука useBanquetForm. + * + * Хук useBanquetForm - критичный хук для управления формой бронирования банкета. + * Отвечает за управление состоянием формы, валидацию и создание запроса на бронирование. + * + * Тесты покрывают: + * - Установку данных банкета + * - Управление дополнительными услугами + * - Обновление полей формы + * - Сброс формы + * - Создание запроса на бронирование + * - Навигацию между страницами + * - Валидацию данных + * + * @module __tests__/hooks/useBanquetForm + * + * @see {@link useBanquetForm} - тестируемый хук + */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { useBanquetForm } from '@/hooks/useBanquetForm.ts'; +import { banquetFormAtom, IBanquetFormState } from '@/atoms/banquetFormAtom.ts'; +import { authAtom } from '@/atoms/userAtom.ts'; +import { TestProvider } from '@/__mocks__/atom.mock.tsx'; +import { mockBanquetFormData } from '@/__mocks__/banquets.mock.ts'; +import { mockRestaurantWithBanquets } from '@/__mocks__/restaurant.mock.ts'; + +// ============================================ +// Моки внешних зависимостей +// ============================================ + +const mockedNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockedNavigate, +})); + +const mockShowToast = jest.fn(); +jest.mock('@/hooks/useToastState', () => ({ + __esModule: true, + default: () => ({ + showToast: mockShowToast, + }), +})); + +const mockAPIPostBanquetRequest = jest.fn(); +jest.mock('@/api/banquet.api.ts', () => ({ + __esModule: true, + APIPostBanquetRequest: (...args: any[]) => mockAPIPostBanquetRequest(...args), +})); + +// Мокируем moment для тестов +jest.mock('moment', () => { + const actualMoment = jest.requireActual('moment'); + const momentFn = actualMoment.default || actualMoment; + return { + __esModule: true, + default: momentFn, + ...actualMoment, + }; +}); + +// ============================================ +// Вспомогательные функции +// ============================================ + +const renderHookWithProvider = ( + initialForm?: Partial, + auth?: { access_token: string } | null +) => { + // Если auth явно передан как null, используем null, иначе используем дефолтное значение + const authValue = auth === null ? null : (auth ?? { access_token: 'test_token' }); + + const initialValues: any[] = [ + [banquetFormAtom, { ...mockBanquetFormData, ...initialForm }], + [authAtom, authValue], + ]; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook(() => useBanquetForm(), { wrapper }); +}; + +describe('useBanquetForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setBanquetData', () => { + it('должен установить основные данные банкета', () => { + const { result } = renderHookWithProvider(); + + const banquetData = { + name: 'Банкетный зал', + date: new Date('2026-02-15'), + timeFrom: '18:00', + timeTo: '22:00', + guestCount: { value: '25', title: '25 человек' }, + reason: 'День рождения', + currentRestaurant: mockRestaurantWithBanquets, + restaurantId: '1', + optionId: '14', + additionalOptions: [{ id: 1, name: 'Услуга 1' }], + withAdditionalPage: true, + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + }; + + act(() => { + result.current.handlers.setBanquetData(banquetData); + }); + + expect(result.current.form.name).toBe('Банкетный зал'); + expect(result.current.form.date).toEqual(new Date('2026-02-15')); + expect(result.current.form.timeFrom).toBe('18:00'); + expect(result.current.form.timeTo).toBe('22:00'); + expect(result.current.form.guestCount.value).toBe('25'); + expect(result.current.form.reason).toBe('День рождения'); + expect(result.current.form.selectedServices).toEqual([]); + }); + + it('должен сбросить selectedServices при установке данных', () => { + const { result } = renderHookWithProvider({ + selectedServices: ['Услуга 1', 'Услуга 2'], + }); + + act(() => { + result.current.handlers.setBanquetData({ + name: 'Новый зал', + date: new Date('2026-02-15'), + timeFrom: '18:00', + timeTo: '22:00', + guestCount: { value: '25', title: '25 человек' }, + reason: 'Свадьба', + currentRestaurant: mockRestaurantWithBanquets, + restaurantId: '1', + optionId: '14', + additionalOptions: [], + withAdditionalPage: false, + price: null, + }); + }); + + expect(result.current.form.selectedServices).toEqual([]); + }); + }); + + describe('setSelectedServices', () => { + it('должен установить выбранные услуги', () => { + const { result } = renderHookWithProvider(); + + act(() => { + result.current.handlers.setSelectedServices(['Услуга 1', 'Услуга 2']); + }); + + expect(result.current.form.selectedServices).toEqual(['Услуга 1', 'Услуга 2']); + expect(result.current.form.withAdditionalPage).toBe(true); + }); + + it('должен заменить существующие услуги', () => { + const { result } = renderHookWithProvider({ + selectedServices: ['Старая услуга'], + }); + + act(() => { + result.current.handlers.setSelectedServices(['Новая услуга']); + }); + + expect(result.current.form.selectedServices).toEqual(['Новая услуга']); + }); + }); + + describe('toggleService', () => { + it('должен добавить услугу, если её нет в списке', () => { + const { result } = renderHookWithProvider({ + selectedServices: ['Услуга 1'], + }); + + act(() => { + result.current.handlers.toggleService('Услуга 2'); + }); + + expect(result.current.form.selectedServices).toEqual(['Услуга 1', 'Услуга 2']); + }); + + it('должен удалить услугу, если она уже в списке', () => { + const { result } = renderHookWithProvider({ + selectedServices: ['Услуга 1', 'Услуга 2'], + }); + + act(() => { + result.current.handlers.toggleService('Услуга 1'); + }); + + expect(result.current.form.selectedServices).toEqual(['Услуга 2']); + }); + + it('должен добавить услугу в пустой список', () => { + const { result } = renderHookWithProvider({ + selectedServices: [], + }); + + act(() => { + result.current.handlers.toggleService('Услуга 1'); + }); + + expect(result.current.form.selectedServices).toEqual(['Услуга 1']); + }); + }); + + describe('updateField', () => { + it('должен обновить отдельные поля формы', () => { + const { result } = renderHookWithProvider(); + + act(() => { + result.current.handlers.updateField({ + reason: 'Свадьба', + timeFrom: '19:00', + }); + }); + + expect(result.current.form.reason).toBe('Свадьба'); + expect(result.current.form.timeFrom).toBe('19:00'); + }); + + it('должен сохранить другие поля при обновлении', () => { + const { result } = renderHookWithProvider({ + name: 'Банкетный зал', + reason: 'День рождения', + }); + + act(() => { + result.current.handlers.updateField({ + reason: 'Свадьба', + }); + }); + + expect(result.current.form.name).toBe('Банкетный зал'); + expect(result.current.form.reason).toBe('Свадьба'); + }); + }); + + describe('resetForm', () => { + it('должен сбросить форму к начальному состоянию', () => { + const { result } = renderHookWithProvider({ + name: 'Банкетный зал', + date: new Date('2026-02-15'), + timeFrom: '18:00', + timeTo: '22:00', + reason: 'День рождения', + selectedServices: ['Услуга 1'], + restaurantId: '1', + optionId: '14', + }); + + act(() => { + result.current.handlers.resetForm(); + }); + + expect(result.current.form.name).toBeUndefined(); + expect(result.current.form.date).toBeNull(); + expect(result.current.form.timeFrom).toBe('с'); + expect(result.current.form.timeTo).toBe('до'); + expect(result.current.form.reason).toBe(''); + expect(result.current.form.selectedServices).toEqual([]); + expect(result.current.form.restaurantId).toBe(''); + expect(result.current.form.optionId).toBe(''); + }); + }); + + describe('createBanquetRequest', () => { + it('должен создать запрос на бронирование при валидных данных', async () => { + // Настраиваем мок для успешного ответа ПЕРЕД созданием формы + mockAPIPostBanquetRequest.mockResolvedValue({ + data: { status: 'success' }, + }); + + const formData = { + date: new Date('2026-02-15'), + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + restaurantId: '1', + optionId: '14', + timeFrom: '18:00', + timeTo: '22:00', + guestCount: { value: '25', title: '25 человек' }, + reason: 'День рождения', + selectedServices: ['Услуга 1'], + }; + + const { result } = renderHookWithProvider(formData); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + // Проверяем, что не было ошибок валидации + expect(mockShowToast).not.toHaveBeenCalledWith('Не все данные заполнены'); + expect(mockShowToast).not.toHaveBeenCalledWith('Необходимо авторизоваться'); + + // Проверяем, что API был вызван + expect(mockAPIPostBanquetRequest).toHaveBeenCalled(); + expect(mockAPIPostBanquetRequest).toHaveBeenCalledWith('test_token', expect.objectContaining({ + restaurant_id: 1, + banquet_option: '14', + start_time: '18:00', + end_time: '22:00', + guests_count: 25, + occasion: 'День рождения', + additional_services: ['Услуга 1'], + comment: 'Комментарий', + contact_method: 'telegram', + estimated_cost: 110000, + })); + + expect(success).toBe(true); + expect(mockShowToast).toHaveBeenCalledWith( + 'Ваш запрос на бронирование банкета принят. Наш менеджер скоро свяжется с вами.' + ); + expect(mockedNavigate).toHaveBeenCalledWith('/'); + }); + + it('должен показать ошибку при отсутствии авторизации', async () => { + const { result } = renderHookWithProvider( + { + date: new Date('2026-02-15'), + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + }, + null + ); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + expect(success).toBe(false); + expect(mockShowToast).toHaveBeenCalledWith('Необходимо авторизоваться'); + expect(mockAPIPostBanquetRequest).not.toHaveBeenCalled(); + }); + + it('должен показать ошибку при отсутствии даты', async () => { + const { result } = renderHookWithProvider({ + date: null, + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + }); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + expect(success).toBe(false); + expect(mockShowToast).toHaveBeenCalledWith('Не все данные заполнены'); + expect(mockAPIPostBanquetRequest).not.toHaveBeenCalled(); + }); + + it('должен показать ошибку при отсутствии цены', async () => { + const { result } = renderHookWithProvider({ + date: new Date('2026-02-15'), + price: null, + }); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + expect(success).toBe(false); + expect(mockShowToast).toHaveBeenCalledWith('Не все данные заполнены'); + expect(mockAPIPostBanquetRequest).not.toHaveBeenCalled(); + }); + + it('должен обработать ошибку API', async () => { + mockAPIPostBanquetRequest.mockRejectedValue(new Error('API Error')); + + const { result } = renderHookWithProvider({ + date: new Date('2026-02-15'), + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + restaurantId: '1', + optionId: '14', + }); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + expect(success).toBe(false); + expect(mockShowToast).toHaveBeenCalledWith('Произошла ошибка при создании запроса'); + expect(consoleSpy).toHaveBeenCalledWith('Banquet request error:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it('должен обработать неуспешный ответ API', async () => { + mockAPIPostBanquetRequest.mockResolvedValue({ + data: { status: 'error' }, + }); + + const { result } = renderHookWithProvider({ + date: new Date('2026-02-15'), + price: { + deposit: 5000, + totalDeposit: 100000, + serviceFee: 10, + total: 110000, + }, + restaurantId: '1', + optionId: '14', + }); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.createBanquetRequest('Комментарий', 'telegram'); + }); + + expect(success).toBe(false); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('navigateToNextPage', () => { + it('должен перейти на страницу дополнительных услуг, если они есть', () => { + const { result } = renderHookWithProvider({ + additionalOptions: [ + { id: 1, name: 'Услуга 1' }, + { id: 2, name: 'Услуга 2' }, + ], + restaurantId: '1', + optionId: '14', + }); + + act(() => { + result.current.navigateToNextPage(); + }); + + expect(mockedNavigate).toHaveBeenCalledWith('/banquets/1/additional-services/14'); + }); + + it('должен перейти на страницу резервации, если нет дополнительных услуг', () => { + const { result } = renderHookWithProvider({ + additionalOptions: [], + restaurantId: '1', + optionId: '14', + }); + + act(() => { + result.current.navigateToNextPage(); + }); + + expect(mockedNavigate).toHaveBeenCalledWith('/banquets/1/reservation'); + }); + }); + + describe('navigateToReservation', () => { + it('должен перейти на страницу резервации', () => { + const { result } = renderHookWithProvider({ + restaurantId: '1', + }); + + act(() => { + result.current.navigateToReservation(); + }); + + expect(mockedNavigate).toHaveBeenCalledWith('/banquets/1/reservation'); + }); + }); +}); diff --git a/src/__tests__/useBookingForm.test.tsx b/src/__tests__/hooks/useBookingForm.test.tsx similarity index 100% rename from src/__tests__/useBookingForm.test.tsx rename to src/__tests__/hooks/useBookingForm.test.tsx diff --git a/src/__tests__/hooks/useCachedData.test.tsx b/src/__tests__/hooks/useCachedData.test.tsx new file mode 100644 index 00000000..c44aa30f --- /dev/null +++ b/src/__tests__/hooks/useCachedData.test.tsx @@ -0,0 +1,306 @@ +/** + * @fileoverview Тесты для хука useCachedData. + * + * Хук useCachedData - инфраструктурный хук для кэширования данных с паттерном stale-while-revalidate. + * Критичен для производительности приложения. + * + * Тесты покрывают: + * - Сохранение и получение данных из кэша + * - Проверку устаревания кэша (TTL) + * - Паттерн stale-while-revalidate + * - Очистку кэша + * - Утилиты getCachedData и setCachedData + * + * @module __tests__/hooks/useCachedData + * + * @see {@link useCachedData} - тестируемый хук + */ + +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useCachedData, getCachedData, setCachedData } from '@/hooks/useCachedData.ts'; +import { localStorageMock, setupLocalStorageMock } from '@/__mocks__/localStorage.mock.ts'; + +// Мокируем localStorage +setupLocalStorageMock(); + +describe('useCachedData', () => { + beforeEach(() => { + localStorageMock.clear(); + jest.clearAllMocks(); + }); + + describe('getFromCache и saveToCache', () => { + it('должен сохранять и получать данные из кэша', () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 1000 }) + ); + + act(() => { + result.current.saveToCache('test data'); + }); + + const cached = result.current.getFromCache(); + expect(cached).not.toBeNull(); + expect(cached?.data).toBe('test data'); + expect(cached?.timestamp).toBeGreaterThan(0); + }); + + it('должен возвращать null для несуществующего кэша', () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 1000 }) + ); + + const cached = result.current.getFromCache(); + expect(cached).toBeNull(); + }); + + it('должен обрабатывать ошибки при чтении кэша', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const originalGetItem = localStorageMock.getItem; + localStorageMock.getItem = jest.fn(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 1000 }) + ); + + const cached = result.current.getFromCache(); + expect(cached).toBeNull(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + localStorageMock.getItem = originalGetItem; + }); + }); + + describe('isCacheStale', () => { + it('должен определить свежий кэш как не устаревший', () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 10000 }) + ); + + act(() => { + result.current.saveToCache('test data'); + }); + + const cached = result.current.getFromCache(); + expect(cached).not.toBeNull(); + expect(result.current.isCacheStale(cached!)).toBe(false); + }); + + it('должен определить устаревший кэш', () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 100 }) + ); + + // Сохраняем данные с устаревшим timestamp + const staleEntry = { + data: 'test data', + timestamp: Date.now() - 200, // Кэш старше TTL (100ms) + }; + localStorageMock.setItem('cache_test', JSON.stringify(staleEntry)); + + const cached = result.current.getFromCache(); + expect(cached).not.toBeNull(); + expect(result.current.isCacheStale(cached!)).toBe(true); + }); + }); + + describe('getCachedOrFetch', () => { + it('должен вернуть данные из кэша, если они есть', async () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 10000 }) + ); + + act(() => { + result.current.saveToCache('cached data'); + }); + + const fetchFn = jest.fn().mockResolvedValue({ data: 'fresh data' }); + const onDataReceived = jest.fn(); + + let data: string | undefined; + await act(async () => { + data = await result.current.getCachedOrFetch(fetchFn, onDataReceived); + }); + + expect(data).toBe('cached data'); + expect(fetchFn).not.toHaveBeenCalled(); + expect(onDataReceived).toHaveBeenCalledWith('cached data', true); + }); + + it('должен сделать запрос, если кэша нет', async () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 10000 }) + ); + + const fetchFn = jest.fn().mockResolvedValue({ data: 'fresh data' }); + const onDataReceived = jest.fn(); + + let data: string | undefined; + await act(async () => { + data = await result.current.getCachedOrFetch(fetchFn, onDataReceived); + }); + + expect(data).toBe('fresh data'); + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(onDataReceived).toHaveBeenCalledWith('fresh data', false); + + // Проверяем, что данные сохранились в кэш + const cached = result.current.getFromCache(); + expect(cached?.data).toBe('fresh data'); + }); + + it('должен обновить устаревший кэш в фоне (stale-while-revalidate)', async () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 100 }) + ); + + act(() => { + result.current.saveToCache('stale data'); + }); + + // Симулируем устаревший кэш через прямое изменение timestamp в localStorage + const staleEntry = { + data: 'stale data', + timestamp: Date.now() - 200, // Кэш старше TTL + }; + localStorageMock.setItem('cache_test', JSON.stringify(staleEntry)); + + const fetchFn = jest.fn().mockResolvedValue({ data: 'fresh data' }); + const onDataReceived = jest.fn(); + + let data: string | undefined; + await act(async () => { + data = await result.current.getCachedOrFetch(fetchFn, onDataReceived); + }); + + // Сразу возвращает устаревшие данные + expect(data).toBe('stale data'); + expect(onDataReceived).toHaveBeenCalledWith('stale data', true); + + // Ждем фонового обновления + await waitFor(() => { + expect(fetchFn).toHaveBeenCalled(); + }, { timeout: 3000 }); + + await waitFor(() => { + expect(onDataReceived).toHaveBeenCalledWith('fresh data', false); + }, { timeout: 3000 }); + + // Проверяем, что кэш обновился + const cached = result.current.getFromCache(); + expect(cached?.data).toBe('fresh data'); + }); + + it('не должен делать несколько фоновых обновлений одновременно', async () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 100 }) + ); + + // Симулируем устаревший кэш + const staleEntry = { + data: 'stale data', + timestamp: Date.now() - 200, + }; + localStorageMock.setItem('cache_test', JSON.stringify(staleEntry)); + + const fetchFn = jest.fn().mockResolvedValue({ data: 'fresh data' }); + + // Вызываем несколько раз подряд + await act(async () => { + await Promise.all([ + result.current.getCachedOrFetch(fetchFn), + result.current.getCachedOrFetch(fetchFn), + result.current.getCachedOrFetch(fetchFn), + ]); + }); + + // Должен быть только один запрос + await waitFor(() => { + expect(fetchFn).toHaveBeenCalledTimes(1); + }, { timeout: 3000 }); + }); + + it('должен обработать ошибку при запросе', async () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 10000 }) + ); + + const fetchFn = jest.fn().mockRejectedValue(new Error('API Error')); + + await act(async () => { + await expect( + result.current.getCachedOrFetch(fetchFn) + ).rejects.toThrow('API Error'); + }); + + // Проверяем, что функция была вызвана + expect(fetchFn).toHaveBeenCalled(); + }); + }); + + describe('clearCache', () => { + it('должен очистить кэш', () => { + const { result } = renderHook(() => + useCachedData({ cacheKey: 'test', ttl: 10000 }) + ); + + act(() => { + result.current.saveToCache('test data'); + }); + + expect(result.current.getFromCache()).not.toBeNull(); + + act(() => { + result.current.clearCache(); + }); + + expect(result.current.getFromCache()).toBeNull(); + }); + }); +}); + +describe('getCachedData и setCachedData (утилиты)', () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + it('должен сохранять и получать данные через утилиты', () => { + setCachedData('test', 'test data'); + const data = getCachedData('test'); + expect(data).toBe('test data'); + }); + + it('должен возвращать null для несуществующего кэша', () => { + const data = getCachedData('nonexistent'); + expect(data).toBeNull(); + }); + + it('должен обрабатывать ошибки при чтении', () => { + const originalGetItem = localStorageMock.getItem; + localStorageMock.getItem = jest.fn(() => { + throw new Error('Storage error'); + }); + + const data = getCachedData('test'); + expect(data).toBeNull(); + + localStorageMock.getItem = originalGetItem; + }); + + it('должен обрабатывать ошибки при сохранении', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const originalSetItem = localStorageMock.setItem; + localStorageMock.setItem = jest.fn(() => { + throw new Error('Storage error'); + }); + + setCachedData('test', 'test data'); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + localStorageMock.setItem = originalSetItem; + }); +}); diff --git a/src/__tests__/hooks/useDataLoader.test.tsx b/src/__tests__/hooks/useDataLoader.test.tsx new file mode 100644 index 00000000..d5d150b3 --- /dev/null +++ b/src/__tests__/hooks/useDataLoader.test.tsx @@ -0,0 +1,400 @@ +/** + * @fileoverview Тесты для хука useDataLoader. + * + * Хук useDataLoader - критичный инфраструктурный хук для загрузки данных приложения. + * Реализует приоритизацию загрузки, кэширование и ленивую загрузку. + * + * Тесты покрывают: + * - Загрузку критичных данных (города, рестораны) + * - Кэширование данных + * - Фоновую загрузку событий + * - Ленивую загрузку сертификатов + * - Проверку наличия кэшированных данных + * - Сброс флагов загрузки + * + * @module __tests__/hooks/useDataLoader + * + * @see {@link useDataLoader} - тестируемый хук + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useDataLoader } from '@/hooks/useDataLoader.ts'; +import { authAtom, userAtom } from '@/atoms/userAtom.ts'; +import { cityListAtom } from '@/atoms/cityListAtom.ts'; +import { restaurantsListAtom } from '@/atoms/restaurantsListAtom.ts'; +import { certificatesListAtom } from '@/atoms/certificatesListAtom.ts'; +import { eventsListAtom } from '@/atoms/eventListAtom.ts'; +import { TestProvider } from '@/__mocks__/atom.mock.tsx'; +import { mockCityList } from '@/__mocks__/city.mock.ts'; +import { mockRestaurantWithBanquets } from '@/__mocks__/restaurant.mock.ts'; +import { mockUserData } from '@/__mocks__/user.mock.ts'; + +// ============================================ +// Моки API +// ============================================ + +const mockAPIGetCityList = jest.fn(); +const mockAPIGetRestaurantsList = jest.fn(); +const mockAPIGetEventsList = jest.fn(); +const mockAPIGetCertificates = jest.fn(); + +jest.mock('@/api/city.api.ts', () => ({ + APIGetCityList: (...args: any[]) => mockAPIGetCityList(...args), +})); + +jest.mock('@/api/restaurants.api.ts', () => ({ + APIGetRestaurantsList: (...args: any[]) => mockAPIGetRestaurantsList(...args), +})); + +jest.mock('@/api/events.api.ts', () => ({ + APIGetEventsList: (...args: any[]) => mockAPIGetEventsList(...args), +})); + +jest.mock('@/api/certificates.api.ts', () => ({ + APIGetCertificates: (...args: any[]) => mockAPIGetCertificates(...args), +})); + +// Мокируем useCachedData утилиты +const mockGetCachedData = jest.fn(); +const mockSetCachedData = jest.fn(); + +jest.mock('@/hooks/useCachedData.ts', () => ({ + getCachedData: (...args: any[]) => mockGetCachedData(...args), + setCachedData: (...args: any[]) => mockSetCachedData(...args), +})); + +// ============================================ +// Вспомогательные функции +// ============================================ + +const renderHookWithProvider = ( + auth?: { access_token: string } | null, + user?: any +) => { + // Если auth явно передан как null, используем null, иначе используем дефолтное значение + const authValue = auth === null ? null : (auth ?? { access_token: 'test_token' }); + // Если user явно передан как null, используем null, иначе используем дефолтное значение + const userValue = user === null ? null : (user ?? mockUserData); + + const initialValues: any[] = [ + [authAtom, authValue], + [userAtom, userValue], + [cityListAtom, []], + [restaurantsListAtom, []], + [certificatesListAtom, []], + [eventsListAtom, null], + ]; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook(() => useDataLoader(), { wrapper }); +}; + +describe('useDataLoader', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetCachedData.mockReturnValue(null); + mockSetCachedData.mockImplementation(() => {}); + }); + + describe('loadCriticalData', () => { + it('должен загрузить критичные данные при наличии авторизации', async () => { + mockAPIGetCityList.mockResolvedValue({ data: mockCityList }); + mockAPIGetRestaurantsList.mockResolvedValue({ + data: [mockRestaurantWithBanquets], + }); + + const { result } = renderHookWithProvider(); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.loadCriticalData(); + }); + + await waitFor(() => { + expect(mockAPIGetCityList).toHaveBeenCalled(); + expect(mockAPIGetRestaurantsList).toHaveBeenCalledWith('test_token'); + }); + + expect(success).toBe(true); + expect(mockSetCachedData).toHaveBeenCalledWith('app_cities', mockCityList); + expect(mockSetCachedData).toHaveBeenCalledWith( + 'app_restaurants', + [mockRestaurantWithBanquets] + ); + }); + + it('должен использовать кэшированные данные, если они есть', async () => { + mockGetCachedData.mockImplementation((key: string) => { + if (key === 'app_cities') return mockCityList; + if (key === 'app_restaurants') return [mockRestaurantWithBanquets]; + return null; + }); + + const { result } = renderHookWithProvider(); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.loadCriticalData(); + }); + + // Должен сразу вернуть true при наличии кэша + expect(success).toBe(true); + + // Но все равно должен обновить данные в фоне + await waitFor(() => { + expect(mockAPIGetCityList).toHaveBeenCalled(); + }); + }); + + it('должен вернуть true без загрузки, если нет авторизации', async () => { + const { result } = renderHookWithProvider(null); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.loadCriticalData(); + }); + + expect(success).toBe(true); + expect(mockAPIGetCityList).not.toHaveBeenCalled(); + }); + + it('должен обработать ошибку загрузки городов', async () => { + mockAPIGetCityList.mockRejectedValue(new Error('API Error')); + mockAPIGetRestaurantsList.mockResolvedValue({ + data: [mockRestaurantWithBanquets], + }); + + const { result } = renderHookWithProvider(); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.loadCriticalData(); + }); + + // Должен вернуть true, если хотя бы один запрос успешен + expect(success).toBe(true); + }); + + it('не должен загружать данные повторно', async () => { + mockAPIGetCityList.mockResolvedValue({ data: mockCityList }); + mockAPIGetRestaurantsList.mockResolvedValue({ + data: [mockRestaurantWithBanquets], + }); + + const { result } = renderHookWithProvider(); + + await act(async () => { + await result.current.loadCriticalData(); + }); + + await act(async () => { + await result.current.loadCriticalData(); + }); + + // Должен быть вызван только один раз + expect(mockAPIGetCityList).toHaveBeenCalledTimes(1); + }); + }); + + describe('loadEvents', () => { + it('должен загрузить события при наличии авторизации', async () => { + const mockEvents = [{ id: 1, title: 'Event 1' }]; + mockAPIGetEventsList.mockResolvedValue({ data: mockEvents }); + + const { result } = renderHookWithProvider(); + + await act(async () => { + await result.current.loadEvents(); + }); + + await waitFor(() => { + expect(mockAPIGetEventsList).toHaveBeenCalledWith('test_token'); + }); + }); + + it('не должен загружать события без авторизации', async () => { + const { result } = renderHookWithProvider(null); + + await act(async () => { + await result.current.loadEvents(); + }); + + expect(mockAPIGetEventsList).not.toHaveBeenCalled(); + }); + + it('не должен загружать события повторно', async () => { + mockAPIGetEventsList.mockResolvedValue({ data: [] }); + + const { result } = renderHookWithProvider(); + + await act(async () => { + await result.current.loadEvents(); + }); + + await act(async () => { + await result.current.loadEvents(); + }); + + expect(mockAPIGetEventsList).toHaveBeenCalledTimes(1); + }); + + it('должен обработать ошибку загрузки событий', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockAPIGetEventsList.mockRejectedValue(new Error('API Error')); + + const { result } = renderHookWithProvider(); + + await act(async () => { + await result.current.loadEvents(); + }); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('loadCertificates', () => { + it('должен загрузить сертификаты при наличии авторизации и пользователя', async () => { + const mockCertificates = [{ id: 1, title: 'Certificate 1' }]; + mockAPIGetCertificates.mockResolvedValue({ data: mockCertificates }); + + const { result } = renderHookWithProvider( + { access_token: 'test_token' }, + { ...mockUserData, id: 123 } + ); + + await act(async () => { + await result.current.loadCertificates(); + }); + + await waitFor(() => { + expect(mockAPIGetCertificates).toHaveBeenCalledWith('test_token', 123); + }); + }); + + it('не должен загружать сертификаты без авторизации', async () => { + const { result } = renderHookWithProvider(null); + + await act(async () => { + await result.current.loadCertificates(); + }); + + expect(mockAPIGetCertificates).not.toHaveBeenCalled(); + }); + + it('не должен загружать сертификаты без пользователя', async () => { + const { result } = renderHookWithProvider( + { access_token: 'test_token' }, + null + ); + + await act(async () => { + await result.current.loadCertificates(); + }); + + expect(mockAPIGetCertificates).not.toHaveBeenCalled(); + }); + }); + + describe('loadBackgroundData', () => { + it('должен запустить фоновую загрузку событий', async () => { + mockAPIGetEventsList.mockResolvedValue({ data: [] }); + + const { result } = renderHookWithProvider(); + + act(() => { + result.current.loadBackgroundData(); + }); + + // Ждем выполнения setTimeout (100ms задержка в хуке) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + await waitFor(() => { + expect(mockAPIGetEventsList).toHaveBeenCalled(); + }); + }); + }); + + describe('hasCachedCriticalData', () => { + it('должен вернуть true при наличии валидного кэша', () => { + mockGetCachedData.mockImplementation((key: string) => { + if (key === 'app_cities') return mockCityList; + if (key === 'app_restaurants') return [mockRestaurantWithBanquets]; + return null; + }); + + const { result } = renderHookWithProvider(); + + let hasCache: boolean | undefined; + act(() => { + hasCache = result.current.hasCachedCriticalData(); + }); + + expect(hasCache).toBe(true); + }); + + it('должен вернуть false при отсутствии кэша', () => { + mockGetCachedData.mockReturnValue(null); + + const { result } = renderHookWithProvider(); + + let hasCache: boolean | undefined; + act(() => { + hasCache = result.current.hasCachedCriticalData(); + }); + + expect(hasCache).toBe(false); + }); + + it('должен вернуть false при пустом кэше', () => { + mockGetCachedData.mockImplementation((key: string) => { + if (key === 'app_cities') return []; + if (key === 'app_restaurants') return []; + return null; + }); + + const { result } = renderHookWithProvider(); + + let hasCache: boolean | undefined; + act(() => { + hasCache = result.current.hasCachedCriticalData(); + }); + + expect(hasCache).toBe(false); + }); + }); + + describe('resetLoadFlags', () => { + it('должен сбросить флаги загрузки', async () => { + mockAPIGetCityList.mockResolvedValue({ data: mockCityList }); + mockAPIGetRestaurantsList.mockResolvedValue({ + data: [mockRestaurantWithBanquets], + }); + + const { result } = renderHookWithProvider(); + + await act(async () => { + await result.current.loadCriticalData(); + }); + + expect(mockAPIGetCityList).toHaveBeenCalledTimes(1); + + act(() => { + result.current.resetLoadFlags(); + }); + + await act(async () => { + await result.current.loadCriticalData(); + }); + + // После сброса должен загрузить снова + expect(mockAPIGetCityList).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/__tests__/hooks/useIndexPageData.test.tsx b/src/__tests__/hooks/useIndexPageData.test.tsx new file mode 100644 index 00000000..9014e52d --- /dev/null +++ b/src/__tests__/hooks/useIndexPageData.test.tsx @@ -0,0 +1,499 @@ +/** + * @fileoverview Тесты для хука useIndexPageData. + * + * Хук useIndexPageData - критичный хук для загрузки данных главной страницы. + * Оптимизирует загрузку бронирований, билетов, историй и фильтрацию ресторанов. + * + * Тесты покрывают: + * - Загрузку бронирований и билетов + * - Загрузку историй с кэшированием + * - Фильтрацию ресторанов по городу + * - Отмену запросов при размонтировании + * - Кэширование историй + * + * @module __tests__/hooks/useIndexPageData + * + * @see {@link useIndexPageData} - тестируемый хук + */ + +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useIndexPageData } from '@/hooks/useIndexPageData.ts'; +import { authAtom } from '@/atoms/userAtom.ts'; +import { restaurantsListAtom } from '@/atoms/restaurantsListAtom.ts'; +import { TestProvider } from '@/__mocks__/atom.mock.tsx'; +import { mockRestaurantWithBanquets } from '@/__mocks__/restaurant.mock.ts'; +import { R } from '@/__mocks__/restaurant.mock.ts'; + +// Мокируем moment для тестов +jest.mock('moment', () => { + const actualMoment = jest.requireActual('moment'); + const momentFn = actualMoment.default || actualMoment; + return { + __esModule: true, + default: momentFn, + ...actualMoment, + }; +}); + +// ============================================ +// Моки API +// ============================================ + +const mockAPIGetCurrentBookings = jest.fn(); +const mockAPIGetTickets = jest.fn(); +const mockApiGetStoriesBlocks = jest.fn(); + +jest.mock('@/api/restaurants.api.ts', () => ({ + APIGetCurrentBookings: (...args: any[]) => mockAPIGetCurrentBookings(...args), +})); + +jest.mock('@/api/events.api.ts', () => ({ + APIGetTickets: (...args: any[]) => mockAPIGetTickets(...args), +})); + +jest.mock('@/api/stories.api.ts', () => ({ + ApiGetStoriesBlocks: (...args: any[]) => mockApiGetStoriesBlocks(...args), +})); + +// ============================================ +// Вспомогательные функции +// ============================================ + +const mockBooking = { + id: '1', + booking_type: 'restaurant', + booking_date: '2026-02-15', + time: '18:00', + restaurant: mockRestaurantWithBanquets, + tags: '', + duration: 120, + guests_count: 4, + children_count: 0, + event_title: '', + booking_status: 'confirmed', + user_comments: '', + certificate_value: 0, + certificate_expired_at: '', + features: [], +}; + +const mockTicket = { + id: 1, + date_start: '2026-02-20T19:00:00Z', + restaurant: mockRestaurantWithBanquets, + guest_count: 2, + event_title: 'Концерт', +}; + +const mockStories = [ + { + id: 1, + restaurant: mockRestaurantWithBanquets, + stories: [ + { + id: 1, + media_url: 'https://example.com/story1.jpg', + media_type: 'image', + }, + ], + }, +]; + +const renderHookWithProvider = ( + currentCity: string = 'spb', + cityId: number = 2, + auth?: { access_token: string } | null, + restaurants?: any[] +) => { + // Если auth явно передан как null, используем null, иначе используем дефолтное значение + const authValue = auth === null ? null : (auth ?? { access_token: 'test_token' }); + const initialValues: any[] = [ + [authAtom, authValue], + [restaurantsListAtom, restaurants ?? [mockRestaurantWithBanquets]], + ]; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return renderHook( + () => useIndexPageData({ currentCity, cityId }), + { wrapper } + ); +}; + +describe('useIndexPageData', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Не настраиваем моки по умолчанию - каждый тест должен настроить свои моки явно + // Используем уникальные cityId для каждого теста, чтобы избежать конфликтов кэша + }); + + describe('loadBookings', () => { + it('должен объединить бронирования и билеты', async () => { + // Настраиваем моки для stories, чтобы избежать ошибок + mockApiGetStoriesBlocks.mockResolvedValue({ + data: [], + }); + + // Настраиваем моки для этого теста + mockAPIGetCurrentBookings.mockResolvedValue({ + data: { currentBookings: [mockBooking] }, + }); + mockAPIGetTickets.mockResolvedValue({ + data: [mockTicket], + }); + + const { result } = renderHookWithProvider(); + + // Ждем, пока промисы разрешатся и состояние обновится + await waitFor( + () => { + // Проверяем, что API были вызваны + expect(mockAPIGetCurrentBookings).toHaveBeenCalledWith('test_token'); + expect(mockAPIGetTickets).toHaveBeenCalledWith('test_token'); + // Проверяем, что данные загружены + expect(result.current.currentBookings).not.toBeNull(); + expect(result.current.currentBookings?.length).toBeGreaterThan(0); + }, + { timeout: 10000 } + ); + + // Проверяем конкретное количество элементов + expect(result.current.currentBookings?.length).toBe(2); + + // Проверяем, что данные объединены правильно + // Билеты преобразуются в события и добавляются первыми, затем бронирования + expect(result.current.currentBookings).not.toBeNull(); + expect(result.current.currentBookings?.length).toBe(2); + + const eventBooking = result.current.currentBookings?.find(b => b.booking_type === 'event'); + const restaurantBooking = result.current.currentBookings?.find(b => b.booking_type === 'restaurant'); + expect(eventBooking).toBeDefined(); + expect(restaurantBooking).toBeDefined(); + }, 20000); // Увеличиваем timeout для всего теста + + it('не должен загружать данные без авторизации', async () => { + // Настраиваем моки (хотя они не должны быть вызваны) + mockAPIGetCurrentBookings.mockResolvedValue({ + data: { currentBookings: [] }, + }); + mockAPIGetTickets.mockResolvedValue({ + data: [], + }); + + const { result } = renderHookWithProvider('spb', 2, null); + + // Ждем немного, чтобы убедиться, что useEffect не запустился + await waitFor(() => { + // Проверяем, что API не были вызваны и данные не загружены + expect(mockAPIGetCurrentBookings).not.toHaveBeenCalled(); + expect(result.current.currentBookings).toBeNull(); + }, { timeout: 1000 }); + }); + + it('не должен загружать данные повторно', async () => { + mockAPIGetCurrentBookings.mockResolvedValue({ + data: { currentBookings: [] }, + }); + mockAPIGetTickets.mockResolvedValue({ data: [] }); + + const { result, rerender } = renderHookWithProvider(); + + await waitFor(() => { + expect(result.current.currentBookings).not.toBeNull(); + }); + + rerender(); + + // Должен быть вызван только один раз + expect(mockAPIGetCurrentBookings).toHaveBeenCalledTimes(1); + }); + + it('должен обработать ошибку загрузки', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + mockAPIGetCurrentBookings.mockRejectedValue(new Error('API Error')); + mockAPIGetTickets.mockRejectedValue(new Error('API Error')); + + const { result } = renderHookWithProvider(); + + await waitFor(() => { + expect(result.current.currentBookings).toEqual([]); + }); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('loadStories', () => { + it('должен загрузить истории для города', async () => { + // Используем уникальный cityId, чтобы избежать конфликтов кэша + const uniqueCityId = 990; + + // Настраиваем моки для этого теста + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + const { result } = renderHookWithProvider('spb', uniqueCityId); + + await waitFor(() => { + expect(result.current.storiesBlocks).not.toBeNull(); + }, { timeout: 5000 }); + + expect(mockApiGetStoriesBlocks).toHaveBeenCalledWith('test_token', uniqueCityId); + }); + + it('должен использовать кэш историй', async () => { + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + const { result: result1 } = renderHookWithProvider('spb', 2); + + await waitFor(() => { + expect(result1.current.storiesBlocks).not.toBeNull(); + }); + + // Второй вызов должен использовать кэш + const { result: result2 } = renderHookWithProvider('spb', 2); + + // Должен сразу вернуть кэшированные данные + await waitFor(() => { + expect(result2.current.storiesBlocks).not.toBeNull(); + }, { timeout: 1000 }); + }); + + it('должен использовать кэш историй при повторном запросе', async () => { + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + // Используем уникальный cityId, чтобы гарантировать, что кэш пуст при первом запросе + const uniqueCityId = 996; + const { result: result1 } = renderHookWithProvider('spb', uniqueCityId); + + await waitFor(() => { + expect(result1.current.storiesBlocks).not.toBeNull(); + }); + + expect(mockApiGetStoriesBlocks).toHaveBeenCalledTimes(1); + + // Очищаем моки перед вторым запросом + mockApiGetStoriesBlocks.mockClear(); + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + // Второй вызов с тем же cityId должен использовать кэш + const { result: result2 } = renderHookWithProvider('spb', uniqueCityId); + + // Кэш работает через внутренний Map в хуке + // Проверяем, что данные доступны сразу (из кэша) + await waitFor(() => { + expect(result2.current.storiesBlocks).not.toBeNull(); + }); + + // API не должен быть вызван второй раз, так как используется кэш + expect(mockApiGetStoriesBlocks).not.toHaveBeenCalled(); + }); + + it('не должен загружать истории без авторизации', async () => { + // Используем уникальный cityId, чтобы избежать использования кэша из предыдущих тестов + const uniqueCityId = 997; + const { result } = renderHookWithProvider('spb', uniqueCityId, null); + + await waitFor(() => { + // Проверяем, что API не был вызван и данные не загружены + expect(mockApiGetStoriesBlocks).not.toHaveBeenCalled(); + expect(result.current.storiesBlocks).toBeNull(); + }, { timeout: 1000 }); + }); + + it('не должен загружать истории без cityId', async () => { + // Используем уникальный cityId=0, чтобы избежать использования кэша + const uniqueCityId = 0; + renderHookWithProvider('spb', uniqueCityId); + + await waitFor(() => { + // Проверяем, что API не был вызван + expect(mockApiGetStoriesBlocks).not.toHaveBeenCalled(); + }, { timeout: 1000 }); + }); + + it('должен загрузить истории для нового города', async () => { + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + // Используем уникальные cityId, которые точно не были использованы в предыдущих тестах + const firstCityId = 999; + const secondCityId = 998; + + const { result: result1, unmount: unmount1 } = renderHookWithProvider('spb', firstCityId); + + await waitFor(() => { + expect(result1.current.storiesBlocks).not.toBeNull(); + expect(mockApiGetStoriesBlocks).toHaveBeenCalledWith('test_token', firstCityId); + }); + + // Размонтируем первый хук + unmount1(); + + // Очищаем моки и настраиваем заново + mockApiGetStoriesBlocks.mockClear(); + mockApiGetStoriesBlocks.mockResolvedValue({ + data: mockStories, + }); + + // Загружаем истории для другого города с другим cityId + const { result: result2 } = renderHookWithProvider('moscow', secondCityId); + + // Ждем, пока хук загрузит истории + await waitFor(() => { + expect(mockApiGetStoriesBlocks).toHaveBeenCalled(); + expect(mockApiGetStoriesBlocks).toHaveBeenCalledWith('test_token', secondCityId); + expect(result2.current.storiesBlocks).not.toBeNull(); + }, { timeout: 5000 }); + }); + + it('должен фильтровать пустые истории', async () => { + // Используем уникальный cityId, чтобы избежать конфликтов кэша + const uniqueCityId = 991; + + // Настраиваем моки для этого теста + mockApiGetStoriesBlocks.mockResolvedValue({ + data: [ + ...mockStories, + { + id: 2, + restaurant: mockRestaurantWithBanquets, + stories: [], // Пустые истории + }, + ], + }); + + const { result } = renderHookWithProvider('spb', uniqueCityId); + + await waitFor(() => { + expect(result.current.storiesBlocks).not.toBeNull(); + expect(result.current.storiesBlocks?.length).toBe(1); + }, { timeout: 5000 }); + + // Должен отфильтровать пустые истории + expect(result.current.storiesBlocks?.length).toBe(1); + }); + }); + + describe('restaurantsList', () => { + it('должен отфильтровать рестораны по городу', () => { + const spbRestaurant = { + ...mockRestaurantWithBanquets, + id: '1', + city: { + id: 2, + name: 'Санкт-Петербург', + name_english: 'spb', + name_dative: 'Санкт-Петербурге', + }, + }; + + const moscowRestaurant = { + ...mockRestaurantWithBanquets, + id: '2', + city: { + id: 1, + name: 'Москва', + name_english: 'moscow', + name_dative: 'Москве', + }, + }; + + const { result } = renderHookWithProvider( + 'spb', + 2, + { access_token: 'test_token' }, + [spbRestaurant, moscowRestaurant] + ); + + expect(result.current.restaurantsList).toHaveLength(1); + expect(result.current.restaurantsList[0].city.name_english).toBe('spb'); + }); + + it('должен переместить Self Edge Chinois в начало списка', () => { + const chinoisRestaurant = { + ...mockRestaurantWithBanquets, + id: R.SELF_EDGE_SPB_CHINOIS_ID, + city: { + id: 2, + name: 'Санкт-Петербург', + name_english: 'spb', + name_dative: 'Санкт-Петербурге', + }, + }; + + const otherRestaurant = { + ...mockRestaurantWithBanquets, + id: '1', + city: { + id: 2, + name: 'Санкт-Петербург', + name_english: 'spb', + name_dative: 'Санкт-Петербурге', + }, + }; + + const { result } = renderHookWithProvider( + 'spb', + 2, + { access_token: 'test_token' }, + [otherRestaurant, chinoisRestaurant] + ); + + expect(result.current.restaurantsList[0].id).toBe(R.SELF_EDGE_SPB_CHINOIS_ID); + }); + + it('должен вернуть пустой массив для пустого списка ресторанов', () => { + const { result } = renderHookWithProvider( + 'spb', + 2, + { access_token: 'test_token' }, + [] + ); + + expect(result.current.restaurantsList).toEqual([]); + }); + }); + + describe('Отмена запросов', () => { + it('должен отменить запросы при размонтировании', async () => { + // Используем промис, который никогда не разрешится + const neverResolvingPromise = new Promise<{ data: { currentBookings: any[] } }>(() => { + // Промис никогда не разрешается + }); + + mockAPIGetCurrentBookings.mockReturnValue(neverResolvingPromise); + mockAPIGetTickets.mockResolvedValue({ data: [] }); + + const { result, unmount } = renderHookWithProvider(); + + // Ждем начала запроса + await waitFor(() => { + expect(mockAPIGetCurrentBookings).toHaveBeenCalled(); + }, { timeout: 1000 }); + + // Сохраняем текущее состояние перед размонтированием + const stateBeforeUnmount = result.current.currentBookings; + + // Размонтируем до завершения запроса + await act(async () => { + unmount(); + }); + + // Состояние не должно было измениться до размонтирования + expect(stateBeforeUnmount).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/useNavigationHistory.test.tsx b/src/__tests__/hooks/useNavigationHistory.test.tsx similarity index 100% rename from src/__tests__/useNavigationHistory.test.tsx rename to src/__tests__/hooks/useNavigationHistory.test.tsx diff --git a/src/__tests__/useRedirectLogic.test.ts b/src/__tests__/hooks/useRedirectLogic.test.ts similarity index 100% rename from src/__tests__/useRedirectLogic.test.ts rename to src/__tests__/hooks/useRedirectLogic.test.ts diff --git a/src/__tests__/utils/menu.utils.test.ts b/src/__tests__/utils/menu.utils.test.ts new file mode 100644 index 00000000..0d15e495 --- /dev/null +++ b/src/__tests__/utils/menu.utils.test.ts @@ -0,0 +1,261 @@ +/** + * @fileoverview Тесты для утилит работы с меню. + * + * Утилиты menu.utils содержат функции для работы с ценами и единицами измерения блюд. + * Эти функции критичны для правильного отображения цен и размеров в интерфейсе. + * + * Тесты покрывают: + * - Извлечение цены из различных форматов данных API + * - Получение дефолтного размера блюда + * - Форматирование единиц измерения + * - Граничные случаи и обработку ошибок + * + * @module __tests__/utils/menu.utils + * + * @see {@link menu.utils} - тестируемые утилиты + */ + +import { + extractPrice, + getDefaultSize, + formatMeasureUnitType, +} from '@/utils/menu.utils.ts'; + +describe('menu.utils', () => { + describe('extractPrice', () => { + it('должен извлечь цену из массива с одним объектом', () => { + const prices = [{ weight_500: 1000 }]; + expect(extractPrice(prices)).toBe(1000); + }); + + it('должен извлечь цену из массива с несколькими объектами (берет первый)', () => { + const prices = [ + { weight_500: 1000 }, + { weight_1000: 2000 }, + ]; + expect(extractPrice(prices)).toBe(1000); + }); + + it('должен извлечь цену из вложенного объекта с value', () => { + const prices = [{ weight_500: { value: 1500 } }]; + expect(extractPrice(prices)).toBe(1500); + }); + + it('должен извлечь цену из вложенного объекта с price', () => { + const prices = [{ weight_500: { price: 1200 } }]; + expect(extractPrice(prices)).toBe(1200); + }); + + it('должен извлечь цену из вложенного объекта с amount', () => { + const prices = [{ weight_500: { amount: 1300 } }]; + expect(extractPrice(prices)).toBe(1300); + }); + + it('должен вернуть 0 для пустого массива', () => { + expect(extractPrice([])).toBe(0); + }); + + it('должен вернуть 0 для undefined', () => { + expect(extractPrice(undefined)).toBe(0); + }); + + it('должен вернуть 0 для null', () => { + expect(extractPrice(null as any)).toBe(0); + }); + + it('должен вернуть 0 для пустого объекта', () => { + const prices = [{}]; + expect(extractPrice(prices)).toBe(0); + }); + + it('должен вернуть 0 для объекта без числовых значений', () => { + const prices = [{ weight_500: 'not a number' }]; + expect(extractPrice(prices)).toBe(0); + }); + + it('должен вернуть 0 для вложенного объекта без value/price/amount', () => { + const prices = [{ weight_500: { other: 1000 } }]; + expect(extractPrice(prices)).toBe(0); + }); + + it('должен обработать нулевую цену', () => { + const prices = [{ weight_500: 0 }]; + expect(extractPrice(prices)).toBe(0); + }); + + it('должен обработать отрицательную цену', () => { + const prices = [{ weight_500: -100 }]; + expect(extractPrice(prices)).toBe(-100); + }); + }); + + describe('getDefaultSize', () => { + interface TestSize { + id: number; + is_default?: boolean; + name: string; + } + + it('должен вернуть размер с флагом is_default', () => { + const sizes: TestSize[] = [ + { id: 1, name: 'Small', is_default: false }, + { id: 2, name: 'Medium', is_default: true }, + { id: 3, name: 'Large', is_default: false }, + ]; + + const result = getDefaultSize(sizes); + expect(result).toEqual({ id: 2, name: 'Medium', is_default: true }); + }); + + it('должен вернуть первый элемент, если нет размера с is_default', () => { + const sizes: TestSize[] = [ + { id: 1, name: 'Small' }, + { id: 2, name: 'Medium' }, + { id: 3, name: 'Large' }, + ]; + + const result = getDefaultSize(sizes); + expect(result).toEqual({ id: 1, name: 'Small' }); + }); + + it('должен вернуть первый элемент, если несколько размеров с is_default', () => { + const sizes: TestSize[] = [ + { id: 1, name: 'Small', is_default: true }, + { id: 2, name: 'Medium', is_default: true }, + ]; + + const result = getDefaultSize(sizes); + expect(result).toEqual({ id: 1, name: 'Small', is_default: true }); + }); + + it('должен вернуть undefined для пустого массива', () => { + const sizes: TestSize[] = []; + const result = getDefaultSize(sizes); + expect(result).toBeUndefined(); + }); + + it('должен вернуть первый элемент для массива с одним элементом', () => { + const sizes: TestSize[] = [{ id: 1, name: 'Only' }]; + const result = getDefaultSize(sizes); + expect(result).toEqual({ id: 1, name: 'Only' }); + }); + }); + + describe('formatMeasureUnitType', () => { + describe('Граммы', () => { + it('должен форматировать "GRAM" в "г"', () => { + expect(formatMeasureUnitType('GRAM')).toBe('г'); + }); + + it('должен форматировать "GRAMS" в "г"', () => { + expect(formatMeasureUnitType('GRAMS')).toBe('г'); + }); + + it('должен форматировать "g" в "г"', () => { + expect(formatMeasureUnitType('g')).toBe('г'); + }); + + it('должен форматировать "грамм" в "г"', () => { + expect(formatMeasureUnitType('грамм')).toBe('г'); + }); + + it('должен форматировать "Г" в "г" (регистронезависимо)', () => { + expect(formatMeasureUnitType('Г')).toBe('г'); + }); + }); + + describe('Килограммы', () => { + it('должен форматировать "KILOGRAM" в "кг"', () => { + expect(formatMeasureUnitType('KILOGRAM')).toBe('кг'); + }); + + it('должен форматировать "KG" в "кг"', () => { + expect(formatMeasureUnitType('KG')).toBe('кг'); + }); + + it('должен форматировать "килограмм" в "кг"', () => { + expect(formatMeasureUnitType('килограмм')).toBe('кг'); + }); + }); + + describe('Миллилитры', () => { + it('должен форматировать "MILLILITER" в "мл"', () => { + expect(formatMeasureUnitType('MILLILITER')).toBe('мл'); + }); + + it('должен форматировать "ML" в "мл"', () => { + expect(formatMeasureUnitType('ML')).toBe('мл'); + }); + + it('должен форматировать "миллилитр" в "мл"', () => { + expect(formatMeasureUnitType('миллилитр')).toBe('мл'); + }); + + it('должен форматировать "millilitre" в "мл"', () => { + expect(formatMeasureUnitType('millilitre')).toBe('мл'); + }); + }); + + describe('Литры', () => { + it('должен форматировать "LITER" в "л"', () => { + expect(formatMeasureUnitType('LITER')).toBe('л'); + }); + + it('должен форматировать "L" в "л"', () => { + expect(formatMeasureUnitType('L')).toBe('л'); + }); + + it('должен форматировать "литр" в "л"', () => { + expect(formatMeasureUnitType('литр')).toBe('л'); + }); + + it('должен форматировать "litre" в "л"', () => { + expect(formatMeasureUnitType('litre')).toBe('л'); + }); + }); + + describe('Граничные случаи', () => { + it('должен вернуть пустую строку для undefined', () => { + expect(formatMeasureUnitType(undefined)).toBe(''); + }); + + it('должен вернуть пустую строку для null', () => { + expect(formatMeasureUnitType(null)).toBe(''); + }); + + it('должен вернуть пустую строку для пустой строки', () => { + expect(formatMeasureUnitType('')).toBe(''); + }); + + it('должен обрезать пробелы', () => { + expect(formatMeasureUnitType(' GRAM ')).toBe('г'); + }); + + it('должен вернуть исходное значение для неизвестной единицы', () => { + expect(formatMeasureUnitType('UNKNOWN_UNIT')).toBe('UNKNOWN_UNIT'); + }); + + it('должен вернуть исходное значение для "шт" (штуки)', () => { + expect(formatMeasureUnitType('шт')).toBe('шт'); + }); + + it('должен вернуть исходное значение для "piece"', () => { + expect(formatMeasureUnitType('piece')).toBe('piece'); + }); + }); + + describe('Регистронезависимость', () => { + it('должен обрабатывать разные регистры для граммов', () => { + expect(formatMeasureUnitType('gram')).toBe('г'); + expect(formatMeasureUnitType('GRAM')).toBe('г'); + expect(formatMeasureUnitType('Gram')).toBe('г'); + }); + + it('должен обрабатывать разные регистры для килограммов', () => { + expect(formatMeasureUnitType('kilogram')).toBe('кг'); + expect(formatMeasureUnitType('KILOGRAM')).toBe('кг'); + expect(formatMeasureUnitType('Kilogram')).toBe('кг'); + }); + }); + }); +}); diff --git a/src/__tests__/utils/trigram.utils.test.ts b/src/__tests__/utils/trigram.utils.test.ts new file mode 100644 index 00000000..a96134e6 --- /dev/null +++ b/src/__tests__/utils/trigram.utils.test.ts @@ -0,0 +1,285 @@ +/** + * @fileoverview Тесты для утилит триграммного поиска. + * + * Утилиты trigram.utils содержат функции для нечеткого поиска и фильтрации текста + * с использованием триграмм (коэффициент Дайса). + * Эти функции критичны для функциональности поиска в приложении. + * + * Тесты покрывают: + * - Расчет схожести строк (trigramSimilarity) + * - Проверку соответствия строки запросу (trigramMatch) + * - Фильтрацию массивов (trigramFilter) + * - Граничные случаи и обработку ошибок + * + * @module __tests__/utils/trigram.utils + * + * @see {@link trigram.utils} - тестируемые утилиты + */ + +import { + trigramSimilarity, + trigramMatch, + trigramFilter, +} from '@/utils/trigram.utils.ts'; + +describe('trigram.utils', () => { + describe('trigramSimilarity', () => { + it('должен вернуть 1 для идентичных строк', () => { + expect(trigramSimilarity('hello', 'hello')).toBe(1); + expect(trigramSimilarity('тест', 'тест')).toBe(1); + }); + + it('должен вернуть 0 для пустых строк', () => { + // Для пустых строк getTrigrams добавляет пустую строку в Set, + // поэтому размер Set будет 1, а не 0, и функция вернет 1 для двух пустых строк + expect(trigramSimilarity('hello', '')).toBe(0); + expect(trigramSimilarity('', 'hello')).toBe(0); + // Для двух пустых строк функция может вернуть 1, так как оба Set содержат пустую строку + const emptySimilarity = trigramSimilarity('', ''); + expect(emptySimilarity).toBeGreaterThanOrEqual(0); + }); + + it('должен вернуть 0 для null или undefined', () => { + expect(trigramSimilarity(null as any, 'hello')).toBe(0); + expect(trigramSimilarity('hello', undefined as any)).toBe(0); + expect(trigramSimilarity(null as any, null as any)).toBe(0); + }); + + it('должен вычислить схожесть для похожих строк', () => { + const similarity = trigramSimilarity('hello', 'helo'); + expect(similarity).toBeGreaterThan(0); + expect(similarity).toBeLessThan(1); + }); + + it('должен быть регистронезависимым', () => { + // Функция нормализует строки в нижний регистр, поэтому схожесть должна быть высокой + const similarity1 = trigramSimilarity('Hello', 'hello'); + const similarity2 = trigramSimilarity('HELLO', 'hello'); + expect(similarity1).toBeGreaterThan(0.5); + expect(similarity2).toBeGreaterThan(0.5); + }); + + it('должен обрабатывать короткие строки (меньше 3 символов)', () => { + expect(trigramSimilarity('ab', 'ab')).toBeGreaterThan(0); + expect(trigramSimilarity('a', 'a')).toBeGreaterThan(0); + }); + + it('должен обрабатывать строки с пробелами', () => { + const similarity = trigramSimilarity('hello world', 'hello world'); + expect(similarity).toBe(1); + }); + + it('должен вычислять схожесть для разных строк', () => { + const similarity1 = trigramSimilarity('ресторан', 'ресторация'); + const similarity2 = trigramSimilarity('ресторан', 'кафе'); + + expect(similarity1).toBeGreaterThan(similarity2); + }); + + it('должен обрабатывать специальные символы', () => { + const similarity = trigramSimilarity('café', 'cafe'); + expect(similarity).toBeGreaterThan(0); + }); + }); + + describe('trigramMatch', () => { + it('должен вернуть true для пустого запроса', () => { + expect(trigramMatch('любой текст', '')).toBe(true); + expect(trigramMatch('любой текст', ' ')).toBe(true); + }); + + it('должен вернуть false для пустого текста', () => { + expect(trigramMatch('', 'запрос')).toBe(false); + }); + + it('должен найти точное совпадение подстроки', () => { + expect(trigramMatch('ресторан гурман', 'ресторан')).toBe(true); + expect(trigramMatch('ресторан гурман', 'гурман')).toBe(true); + }); + + it('должен быть регистронезависимым', () => { + expect(trigramMatch('Ресторан Гурман', 'ресторан')).toBe(true); + expect(trigramMatch('РЕСТОРАН', 'ресторан')).toBe(true); + }); + + describe('Короткие запросы (1-2 символа)', () => { + it('должен найти совпадение по началу слова для 1 символа', () => { + expect(trigramMatch('ресторан', 'р')).toBe(true); + expect(trigramMatch('кафе', 'к')).toBe(true); + }); + + it('должен найти совпадение по началу слова для 2 символов', () => { + expect(trigramMatch('ресторан', 'ре')).toBe(true); + expect(trigramMatch('кафе', 'ка')).toBe(true); + }); + + it('должен найти точное совпадение слова', () => { + expect(trigramMatch('ресторан кафе', 'ка')).toBe(true); + }); + + it('должен вернуть false, если нет совпадения', () => { + expect(trigramMatch('ресторан', 'к')).toBe(false); + }); + }); + + describe('Запросы длиной 3 символа', () => { + it('должен использовать более строгий порог для 3 символов', () => { + expect(trigramMatch('ресторан', 'рес')).toBe(true); + expect(trigramMatch('ресторан', 'каф')).toBe(false); + }); + }); + + describe('Однословные запросы', () => { + it('должен найти совпадение по началу слова', () => { + expect(trigramMatch('ресторан гурман', 'рест')).toBe(true); + expect(trigramMatch('кафе бар', 'каф')).toBe(true); + }); + + it('должен найти совпадение по триграмной схожести', () => { + expect(trigramMatch('ресторан', 'ресторация')).toBe(true); + }); + + it('должен использовать более высокий порог для одного слова', () => { + expect(trigramMatch('ресторан', 'кафе', 0.3)).toBe(false); + }); + }); + + describe('Многословные запросы', () => { + it('должен найти все слова запроса в тексте', () => { + expect(trigramMatch('ресторан гурман москва', 'ресторан гурман')).toBe(true); + expect(trigramMatch('ресторан гурман москва', 'ресторан москва')).toBe(true); + }); + + it('должен вернуть false, если хотя бы одно слово не найдено', () => { + expect(trigramMatch('ресторан гурман', 'ресторан кафе')).toBe(false); + }); + + it('должен пропускать очень короткие слова (меньше 2 символов)', () => { + expect(trigramMatch('ресторан гурман', 'ресторан и гурман')).toBe(true); + }); + + it('должен использовать более строгий порог для коротких слов в многословном запросе', () => { + expect(trigramMatch('ресторан гурман', 'рест каф')).toBe(false); + }); + }); + + describe('Пороги схожести', () => { + it('должен использовать переданный порог', () => { + expect(trigramMatch('ресторан', 'ресторация', 0.9)).toBe(false); + expect(trigramMatch('ресторан', 'ресторация', 0.3)).toBe(true); + }); + + it('должен использовать порог по умолчанию 0.3', () => { + expect(trigramMatch('ресторан', 'ресторация')).toBe(true); + }); + }); + }); + + describe('trigramFilter', () => { + interface TestItem { + id: number; + name: string; + description?: string; + } + + const testItems: TestItem[] = [ + { id: 1, name: 'Ресторан Гурман', description: 'Итальянская кухня' }, + { id: 2, name: 'Кафе Бар', description: 'Кофе и завтраки' }, + { id: 3, name: 'Ресторан Москва', description: 'Русская кухня' }, + { id: 4, name: 'Пиццерия', description: 'Итальянская пицца' }, + ]; + + it('должен вернуть все элементы для пустого запроса', () => { + const result = trigramFilter(testItems, '', (item) => item.name); + expect(result).toEqual(testItems); + }); + + it('должен вернуть все элементы для запроса из пробелов', () => { + const result = trigramFilter(testItems, ' ', (item) => item.name); + expect(result).toEqual(testItems); + }); + + it('должен отфильтровать элементы по имени', () => { + const result = trigramFilter(testItems, 'ресторан', (item) => item.name); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 3]); + }); + + it('должен отфильтровать элементы по описанию', () => { + const result = trigramFilter(testItems, 'итальянская', (item) => item.description || ''); + expect(result).toHaveLength(2); + expect(result.map((r) => r.id)).toEqual([1, 4]); + }); + + it('должен использовать комбинированный поиск', () => { + const getSearchableText = (item: TestItem) => `${item.name} ${item.description || ''}`; + const result = trigramFilter(testItems, 'итальянская', getSearchableText); + expect(result).toHaveLength(2); + }); + + it('должен быть регистронезависимым', () => { + const result1 = trigramFilter(testItems, 'ресторан', (item) => item.name); + const result2 = trigramFilter(testItems, 'РЕСТОРАН', (item) => item.name); + expect(result1).toEqual(result2); + }); + + it('должен использовать переданный порог', () => { + const result1 = trigramFilter(testItems, 'рест', (item) => item.name, 0.3); + const result2 = trigramFilter(testItems, 'рест', (item) => item.name, 0.9); + expect(result1.length).toBeGreaterThanOrEqual(result2.length); + }); + + it('должен вернуть пустой массив, если ничего не найдено', () => { + const result = trigramFilter(testItems, 'несуществующий', (item) => item.name); + expect(result).toEqual([]); + }); + + it('должен обрабатывать пустой массив', () => { + const emptyArray: TestItem[] = []; + const result = trigramFilter(emptyArray, 'запрос', (item) => item.name); + expect(result).toEqual([]); + }); + + it('должен обрабатывать многословные запросы', () => { + const result = trigramFilter(testItems, 'ресторан москва', (item) => item.name); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(3); + }); + + it('должен обрабатывать частичные совпадения', () => { + const result = trigramFilter(testItems, 'ресторация', (item) => item.name); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('Граничные случаи', () => { + it('должен обрабатывать строки с пробелами в начале и конце', () => { + expect(trigramMatch(' ресторан ', 'ресторан')).toBe(true); + expect(trigramSimilarity(' ресторан ', 'ресторан')).toBeGreaterThan(0.8); + }); + + it('должен обрабатывать строки с множественными пробелами', () => { + expect(trigramMatch('ресторан гурман', 'ресторан гурман')).toBe(true); + }); + + it('должен обрабатывать специальные символы', () => { + expect(trigramMatch('café-bar', 'cafe')).toBe(true); + expect(trigramMatch('ресторан "Гурман"', 'гурман')).toBe(true); + }); + + it('должен обрабатывать числа в тексте', () => { + expect(trigramMatch('ресторан 123', 'ресторан')).toBe(true); + expect(trigramMatch('ресторан 123', '123')).toBe(true); + }); + + it('должен обрабатывать очень длинные строки', () => { + const longText = 'ресторан '.repeat(100); + expect(trigramMatch(longText, 'ресторан')).toBe(true); + }); + + it('должен обрабатывать очень длинные запросы', () => { + const longQuery = 'ресторан '.repeat(10); + expect(trigramMatch('ресторан гурман', longQuery)).toBe(true); + }); + }); +}); diff --git a/src/components/AgeVerificationPopup/AgeVerificationPopup.module.css b/src/components/AgeVerificationPopup/AgeVerificationPopup.module.css index c0fb3fd0..eff2ad3b 100644 --- a/src/components/AgeVerificationPopup/AgeVerificationPopup.module.css +++ b/src/components/AgeVerificationPopup/AgeVerificationPopup.module.css @@ -62,7 +62,7 @@ } .primaryButton { - background: #CA0E11; + background: var(--button-grey); color: #FFFFFF; } diff --git a/src/components/Banners/Banner.module.css b/src/components/Banners/Banner.module.css index 521ff319..eb164f6a 100644 --- a/src/components/Banners/Banner.module.css +++ b/src/components/Banners/Banner.module.css @@ -1,9 +1,13 @@ .banner { width: 100%; + min-height: 105px; + contain: layout; } .swiper { - padding: 0 15px; + padding: 0 8px!important; + display: flex!important; + justify-content: center!important; } .photo { @@ -12,8 +16,11 @@ background-position: center center; border-radius: 20px; - min-width: 345px; + width: 370px; + min-width: 370px; + max-width: 370px; height: 105px; - + flex-shrink: 0; + cursor: pointer; } \ No newline at end of file diff --git a/src/components/Banners/Banner.tsx b/src/components/Banners/Banner.tsx index e76f7349..1ecdf500 100644 --- a/src/components/Banners/Banner.tsx +++ b/src/components/Banners/Banner.tsx @@ -17,9 +17,9 @@ export const Banner = () => { return (
- + {banners.map((banner, index) => ( - navigate(banner.link)}> + navigate(banner.link)} style={{ display: 'flex', justifyContent: 'center' }}>
))} diff --git a/src/components/BanquetCheckbox/BanquetCheckbox.module.css b/src/components/BanquetCheckbox/BanquetCheckbox.module.css index 619b6efe..a067875a 100644 --- a/src/components/BanquetCheckbox/BanquetCheckbox.module.css +++ b/src/components/BanquetCheckbox/BanquetCheckbox.module.css @@ -21,8 +21,8 @@ transition: all 0.2s ease; &.checked { - background-color: var(--red); - border-color: var(--red); + background-color: var(--button-grey); + border-color: var(--button-grey); } } diff --git a/src/components/BanquetDatepicker/BanquetDatepicker.module.css b/src/components/BanquetDatepicker/BanquetDatepicker.module.css index 49996d99..8f3ab58c 100644 --- a/src/components/BanquetDatepicker/BanquetDatepicker.module.css +++ b/src/components/BanquetDatepicker/BanquetDatepicker.module.css @@ -88,12 +88,12 @@ } .datepickerDay.current { - border: 1px solid var(--red); + border: 1px solid var(--button-grey); border-radius: 6px; } .datepickerDay.active:hover { - background-color: var(--red); + background-color: var(--button-grey); color: #ffffff; } diff --git a/src/components/BookingReminder/BookingReminder.module.css b/src/components/BookingReminder/BookingReminder.module.css index 630c7995..e56566a1 100644 --- a/src/components/BookingReminder/BookingReminder.module.css +++ b/src/components/BookingReminder/BookingReminder.module.css @@ -1,15 +1,28 @@ .swiper { - padding: 0 15px; + padding: 0 8px!important; + display: flex!important; + justify-content: center!important; +} + +.bookingReminderSection { + width: 100%; + contain: layout; } .bookingReminder { - min-width: 345px; - min-height: 75px; padding: 16px; background-color: var(--secondary-background); - box-sizing: border-box; - border-radius: 16px; - cursor: pointer; + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + border-radius: 20px; + + width: 370px; + min-width: 370px; + max-width: 370px; + min-height: 105px; + /* height: 105px; */ + flex-shrink: 0; } .inner { diff --git a/src/components/BookingReminder/BookingReminder.tsx b/src/components/BookingReminder/BookingReminder.tsx index c609fcfa..bd2f9a83 100644 --- a/src/components/BookingReminder/BookingReminder.tsx +++ b/src/components/BookingReminder/BookingReminder.tsx @@ -90,8 +90,8 @@ export const BookingReminder: React.FC = ({ bookings }): * Возвращаем список бронирований. */ return [ -
- +
+ {bookings .filter((book) => { return ( @@ -100,7 +100,7 @@ export const BookingReminder: React.FC = ({ bookings }): ); }) .map((booking) => ( - +
navigateToBooking(booking.id, booking.booking_type ?? '')} @@ -140,6 +140,6 @@ export const BookingReminder: React.FC = ({ bookings }): ))} -
, +
, ]; }; diff --git a/src/components/CheckBoxInput/CheckBoxInput.module.css b/src/components/CheckBoxInput/CheckBoxInput.module.css index a1e1e5e2..eb65d994 100644 --- a/src/components/CheckBoxInput/CheckBoxInput.module.css +++ b/src/components/CheckBoxInput/CheckBoxInput.module.css @@ -25,8 +25,8 @@ transition: all 0.2s ease; &.checked { - background-color: var(--red); - border-color: var(--red); + background-color: var(--button-grey); + border-color: var(--button-grey); } } diff --git a/src/components/ImageViewerPopup/ImageViewerPopup.tsx b/src/components/ImageViewerPopup/ImageViewerPopup.tsx index 9c1ae733..10b50a24 100644 --- a/src/components/ImageViewerPopup/ImageViewerPopup.tsx +++ b/src/components/ImageViewerPopup/ImageViewerPopup.tsx @@ -24,7 +24,7 @@ const StyledPopup = styled(Popup)` } &-content { - width: 100vw; + width: 100vw!important; height: 100vh; padding: 0; } diff --git a/src/components/MenuPopup/MenuPopup.tsx b/src/components/MenuPopup/MenuPopup.tsx index 7f5db3ae..5a485b39 100644 --- a/src/components/MenuPopup/MenuPopup.tsx +++ b/src/components/MenuPopup/MenuPopup.tsx @@ -24,7 +24,7 @@ const StyledPopup = styled(Popup)` } &-content { - width: 100vw; + width: 100vw!important; height: 100vh; padding: 0; } diff --git a/src/components/PageHeader/PageHeader.tsx b/src/components/PageHeader/PageHeader.tsx index 47a628af..1e8a02d7 100644 --- a/src/components/PageHeader/PageHeader.tsx +++ b/src/components/PageHeader/PageHeader.tsx @@ -3,16 +3,18 @@ import { HeaderContent } from '@/components/ContentBlock/HeaderContainer/HeaderC import { RoundedButton } from '@/components/RoundedButton/RoundedButton.tsx'; import { BackIcon } from '@/components/Icons/BackIcon.tsx'; import css from '@/components/PageHeader/PageHeader.module.css'; +import classNames from 'classnames'; interface IPageHeaderProps { title: string; goBack: () => void; spacer?: boolean; + className?: string; } -export const PageHeader: React.FC = ({ title, goBack, spacer = true }): JSX.Element => { +export const PageHeader: React.FC = ({ title, goBack, spacer = true, className }): JSX.Element => { return ( -
+
} action={goBack} /> {spacer &&
} diff --git a/src/components/Stories/StoriesProgress/Progress.module.css b/src/components/Stories/StoriesProgress/Progress.module.css index 3d1e4097..b63c9985 100644 --- a/src/components/Stories/StoriesProgress/Progress.module.css +++ b/src/components/Stories/StoriesProgress/Progress.module.css @@ -23,7 +23,7 @@ } .inner { - background: rgba(245, 42, 45, 0.8); + background: var(--button-grey); height: 100%; max-width: 100%; border-radius: 2px; diff --git a/src/components/TimeSlots/TimeSlots.tsx b/src/components/TimeSlots/TimeSlots.tsx index 1c4a9537..9a7757a6 100644 --- a/src/components/TimeSlots/TimeSlots.tsx +++ b/src/components/TimeSlots/TimeSlots.tsx @@ -445,7 +445,7 @@ export const TimeSlots: React.FC = React.memo( return ( {!hasAnyTimeSlots ? ( -
+
{startElement && startElement} К сожалению, свободных столов не осталось
diff --git a/src/index.tsx b/src/index.tsx index d75e75f5..6cfa5362 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,12 @@ import { init } from '@/init.ts'; import '@telegram-apps/telegram-ui/dist/styles.css'; import './index.css'; +/* Swiper CSS загружаем глобально, чтобы избежать потери стилей при code splitting. + Banner, BookingReminder и другие компоненты используют Swiper; без этого при навигации + порядок загрузки CSS меняется и баннеры «прыгают». */ +import 'swiper/css'; +import 'swiper/css/zoom'; + const root = ReactDOM.createRoot(document.getElementById('root')!); try { diff --git a/src/pages/BanquetReservationPage/BanquetReservation.module.css b/src/pages/BanquetReservationPage/BanquetReservation.module.css index a2364da4..91b4b443 100644 --- a/src/pages/BanquetReservationPage/BanquetReservation.module.css +++ b/src/pages/BanquetReservationPage/BanquetReservation.module.css @@ -115,6 +115,7 @@ gap: 4px; padding-bottom: 1rem; border-bottom: 1px solid var(--light-grey); + margin-left: -15px; .connect_title { font-family: 'Mont', sans-serif; diff --git a/src/pages/BookingPage/BookingPage.module.css b/src/pages/BookingPage/BookingPage.module.css index 644486fe..630b63df 100644 --- a/src/pages/BookingPage/BookingPage.module.css +++ b/src/pages/BookingPage/BookingPage.module.css @@ -24,6 +24,8 @@ text-align: center!important; padding: 0!important; margin: 0!important; + height: auto!important; + line-height: 18px!important; } .headerInfo { diff --git a/src/pages/BookingPage/EventBookingPage.tsx b/src/pages/BookingPage/EventBookingPage.tsx index 429645fe..c62177bf 100644 --- a/src/pages/BookingPage/EventBookingPage.tsx +++ b/src/pages/BookingPage/EventBookingPage.tsx @@ -30,7 +30,7 @@ */ import React, { useEffect, useMemo, useRef } from 'react'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams, Navigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; // Atoms import { eventsListAtom, guestCountAtom, childrenCountAtom } from '@/atoms/eventListAtom.ts'; @@ -45,10 +45,14 @@ import { EventBookingHeader } from './blocks/EventBookingHeader.tsx'; import { BookingWish } from '@/components/BookingWish/BookingWish.tsx'; import { ConfirmationSelect } from '@/components/ConfirmationSelect/ConfirmationSelect.tsx'; import { BookingErrorPopup } from '@/components/BookingErrorPopup/BookingErrorPopup.tsx'; +import { PlaceholderBlock } from '@/components/PlaceholderBlock/PlaceholderBlock.tsx'; // Hooks import { useBookingForm } from '@/hooks/useBookingForm.ts'; +import { useDataLoader } from '@/hooks/useDataLoader.ts'; // Utils import { getServiceFeeData } from '@/mockData.ts'; +// Styles +import css from '@/pages/EventsPage/EventsPage.module.css'; /** * Страница бронирования столика для бесплатного мероприятия. @@ -86,6 +90,8 @@ export const EventBookingPage: React.FC = (): JSX.Element => { const events = useAtomValue(eventsListAtom); /** Ref для кнопки бронирования (используется BottomButtonWrapper) */ const bookingBtn = useRef(null); + /** Загрузка событий (при перезагрузке страницы events пустой) */ + const { loadEvents } = useDataLoader(); /** * Начальное количество гостей из атома. * Устанавливается на странице {@link EventDetailsPage}. @@ -106,6 +112,16 @@ export const EventBookingPage: React.FC = (): JSX.Element => { return events?.find((event) => event.id === Number(eventId)); }, [events, eventId]); + /** + * При перезагрузке страницы eventsListAtom пуст. + * Запрашиваем события, чтобы восстановить данные мероприятия. + */ + useEffect(() => { + if (eventId && !selectedEvent) { + loadEvents(); + } + }, [eventId, selectedEvent, loadEvents]); + /** * Сообщение о сервисном сборе ресторана. * Зависит от ID ресторана мероприятия. @@ -191,6 +207,25 @@ export const EventBookingPage: React.FC = (): JSX.Element => { } }, [confirmationOptions, form.confirmation.id, handlers]); + /** События загружены, но мероприятие не найдено (неверный eventId) → редирект */ + if (eventId && events !== null && !selectedEvent) { + return ; + } + + /** Загрузка данных мероприятия (перезагрузка страницы) → скелетон */ + if (eventId && events === null) { + return ( + + + + + + + + + ); + } + return ( @@ -256,7 +291,7 @@ export const EventBookingPage: React.FC = (): JSX.Element => { {/* Кнопка бронирования */} {
-

Выберите подходящий ресторан

+

Выберите ресторан

Забронируйте стол, делитесь впечатлениями, участвуйте в мероприятиях diff --git a/src/pages/OnboardingPage/stages/StageThree.tsx b/src/pages/OnboardingPage/stages/StageThree.tsx index 67f49338..07b47cc7 100644 --- a/src/pages/OnboardingPage/stages/StageThree.tsx +++ b/src/pages/OnboardingPage/stages/StageThree.tsx @@ -49,9 +49,9 @@ export const StageThree: React.FC = () => {
-

+

Пользовательское соглашение сервиса «Dreamteam concierge» -

+

Приветствуем Вас, Пользователь, и добро пожаловать. Мы рады Вам и благодарим Вас за Выбор нашего Сервиса «Dreamteam concierge», для начала его использования Вам необходимо ознакомиться с нашим diff --git a/src/pages/PreferencesPage/PreferencesPage.module.css b/src/pages/PreferencesPage/PreferencesPage.module.css index d2c47fe9..a3a4e069 100644 --- a/src/pages/PreferencesPage/PreferencesPage.module.css +++ b/src/pages/PreferencesPage/PreferencesPage.module.css @@ -30,7 +30,7 @@ } .stage__active { - background-color: var(--red); + background-color: var(--button-grey); } .logo { @@ -101,7 +101,7 @@ font-family: 'Mont', sans-serif; font-size: 16px; font-weight: 700; - background-color: #CA0E11; + background-color: var(--button-grey); width: 239px; height: 50px; } diff --git a/src/pages/ProfilePage/DeleteUserPopup/DeleteUserPopup.module.css b/src/pages/ProfilePage/DeleteUserPopup/DeleteUserPopup.module.css index 893f2706..16bff265 100644 --- a/src/pages/ProfilePage/DeleteUserPopup/DeleteUserPopup.module.css +++ b/src/pages/ProfilePage/DeleteUserPopup/DeleteUserPopup.module.css @@ -45,7 +45,7 @@ } .button { - background-color: var(--red); + background-color: var(--button-grey); border: none; outline: none; border-radius: 15px; @@ -55,6 +55,7 @@ font-size: 16px; color: white; cursor: pointer; + text-align: center; } .button__disabled { @@ -141,7 +142,7 @@ } .tag__selected { - background-color: var(--red); + background-color: var(--button-grey); color: white; } diff --git a/src/pages/ProfilePage/FeedbackPopup/FeedbackPopup.module.css b/src/pages/ProfilePage/FeedbackPopup/FeedbackPopup.module.css index 6b1ab24a..ad09b4f5 100644 --- a/src/pages/ProfilePage/FeedbackPopup/FeedbackPopup.module.css +++ b/src/pages/ProfilePage/FeedbackPopup/FeedbackPopup.module.css @@ -76,7 +76,7 @@ } .tag__selected { - background-color: var(--red); + background-color: var(--button-grey); color: white; } @@ -91,7 +91,7 @@ } .button { - background-color: var(--red); + background-color: var(--button-grey); border: none; outline: none; border-radius: 15px; diff --git a/src/pages/ProfilePage/ProfilePage.module.css b/src/pages/ProfilePage/ProfilePage.module.css index 7294dddf..3e421126 100644 --- a/src/pages/ProfilePage/ProfilePage.module.css +++ b/src/pages/ProfilePage/ProfilePage.module.css @@ -10,7 +10,7 @@ display: flex; flex-direction: column; padding: 20px 15px; - gap: 32px; + gap: 20px; } .navLinks { @@ -28,7 +28,7 @@ .navLinkTitle { font-family: 'Mont', sans-serif; - font-weight: 700; + font-weight: 600; font-size: 16px; } diff --git a/src/pages/ProfilePage/ProfilePage.tsx b/src/pages/ProfilePage/ProfilePage.tsx index 0841f639..1bd8fac3 100644 --- a/src/pages/ProfilePage/ProfilePage.tsx +++ b/src/pages/ProfilePage/ProfilePage.tsx @@ -38,7 +38,7 @@ export const ProfilePage: React.FC = (): JSX.Element => { }; return ( - + diff --git a/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.module.css b/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.module.css index 746920a6..f2b64cf9 100644 --- a/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.module.css +++ b/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.module.css @@ -232,7 +232,7 @@ .bookButton { width: 100%; height: 50px; - background-color: var(--red); + background-color: var(--button-grey); border: none; border-radius: 15px; font-family: 'Mont', sans-serif; @@ -272,7 +272,7 @@ .errorContainer button { padding: 12px 24px; - background-color: var(--red); + background-color: var(--button-grey); color: white; border: none; border-radius: 10px; diff --git a/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.tsx b/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.tsx index a0fb67fd..9baca09f 100644 --- a/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.tsx +++ b/src/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.tsx @@ -8,9 +8,9 @@ import { BackIcon } from '@/components/Icons/BackIcon.tsx'; // Styles import css from '@/pages/RestaurantDishDetailsPage/RestaurantDishDetailsPage.module.css'; // Utils -import { extractPrice, getDefaultSize, formatMeasureUnitType } from '@/utils/menu.utils.ts'; +import { extractPrice, formatMeasureUnitType } from '@/utils/menu.utils.ts'; // Hooks -import { useRestaurantMenu } from '@/hooks/useRestaurantMenu'; +// import { useRestaurantMenu } from '@/hooks/useRestaurantMenu'; const formatWeight = (weight: string | undefined, weight_unit?: string): string | undefined => { if (!weight) return undefined; @@ -54,7 +54,7 @@ export const RestaurantDishDetailsPage: React.FC = () => { isCocktail?: boolean; }; - const { menuData } = useRestaurantMenu(String(id)); + // const { menuData } = useRestaurantMenu(String(id)); const [selectedWeightIndex, setSelectedWeightIndex] = useState(0); // Используем только собственное изображение блюда из state @@ -213,7 +213,7 @@ export const RestaurantDishDetailsPage: React.FC = () => { {/* Кнопка бронирования */}

diff --git a/src/pages/RestaurantMenuPage/RestaurantMenuPage.module.css b/src/pages/RestaurantMenuPage/RestaurantMenuPage.module.css index bc8b4aed..49f5b85f 100644 --- a/src/pages/RestaurantMenuPage/RestaurantMenuPage.module.css +++ b/src/pages/RestaurantMenuPage/RestaurantMenuPage.module.css @@ -181,7 +181,7 @@ padding: 10px; width: 198px; height: 40px; - background: #CA0E11; + background: var(--button-grey); border-radius: 12px; border: none; font-family: 'Mont', sans-serif; diff --git a/src/pages/RestaurantPage/RestaurantPage.module.css b/src/pages/RestaurantPage/RestaurantPage.module.css index ab976fdf..d0872534 100644 --- a/src/pages/RestaurantPage/RestaurantPage.module.css +++ b/src/pages/RestaurantPage/RestaurantPage.module.css @@ -33,7 +33,7 @@ .RestInfo { /*display: block;*/ position: absolute; - z-index: 999; + z-index: 10; bottom: 0; width: 100%; padding: 8px; @@ -355,7 +355,7 @@ font-size: 14px; font-family: 'Mont', sans-serif; font-weight: 400; - color: var(--red); + color: var(--button-grey); } } diff --git a/src/pages/RestaurantPage/blocks/AboutBlock.tsx b/src/pages/RestaurantPage/blocks/AboutBlock.tsx index 5a85152f..2fc76650 100644 --- a/src/pages/RestaurantPage/blocks/AboutBlock.tsx +++ b/src/pages/RestaurantPage/blocks/AboutBlock.tsx @@ -76,7 +76,7 @@ export const AboutBlock: React.FC = ({ restaurantId }): JSX.El - diff --git a/src/pages/RestaurantPage/blocks/AddressBlock.tsx b/src/pages/RestaurantPage/blocks/AddressBlock.tsx index 18d4884d..fcc31dc4 100644 --- a/src/pages/RestaurantPage/blocks/AddressBlock.tsx +++ b/src/pages/RestaurantPage/blocks/AddressBlock.tsx @@ -79,7 +79,7 @@ export const AddressBlock: React.FC = ({ restaurantId }): JS return (
- {address} +
{address}
); }; @@ -111,7 +111,6 @@ export const AddressBlock: React.FC = ({ restaurantId }): JS
{renderMetroInfo()} -
{address}
diff --git a/src/pages/UserPhoneConfirmation/UserPhoneConfirmationPage.tsx b/src/pages/UserPhoneConfirmation/UserPhoneConfirmationPage.tsx index b7b60443..f20dcd24 100644 --- a/src/pages/UserPhoneConfirmation/UserPhoneConfirmationPage.tsx +++ b/src/pages/UserPhoneConfirmation/UserPhoneConfirmationPage.tsx @@ -107,7 +107,7 @@ export const UserPhoneConfirmationPage: React.FC = (): JSX.Element => { if (mainButton.mount.isAvailable()) { mainButton.mount(); mainButton.setParams({ - backgroundColor: '#F52A2D', + backgroundColor: '#BBB3A0', hasShineEffect: false, isEnabled: true, isLoaderVisible: false, diff --git a/src/utils/trigram.utils.ts b/src/utils/trigram.utils.ts index 3a778184..dc5ffd22 100644 --- a/src/utils/trigram.utils.ts +++ b/src/utils/trigram.utils.ts @@ -1,10 +1,23 @@ +/** + * Нормализует строку: приводит к нижнему регистру и удаляет диакритические знаки + * @param str - Входная строка + * @returns Нормализованная строка + */ +const normalizeString = (str: string): string => { + return str + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') // Удаляем диакритические знаки + .trim(); +}; + /** * Создает набор триграмм из строки * @param str - Входная строка * @returns Set триграмм */ const getTrigrams = (str: string): Set => { - const normalized = str.toLowerCase().trim(); + const normalized = normalizeString(str); const trigrams = new Set(); if (normalized.length < 3) { @@ -60,8 +73,8 @@ export const trigramMatch = (text: string, query: string, threshold: number = 0. if (!query.trim()) return true; if (!text) return false; - const normalizedText = text.toLowerCase().trim(); - const normalizedQuery = query.toLowerCase().trim(); + const normalizedText = normalizeString(text); + const normalizedQuery = normalizeString(query); // Для очень коротких запросов (1-2 символа) используем только точное совпадение if (normalizedQuery.length <= 2) {