diff --git a/TESTS.md b/TESTS.md index 48e6854..41b5ff9 100644 --- a/TESTS.md +++ b/TESTS.md @@ -2,21 +2,93 @@ Файл поддерживается вместе с unit-тестами. На один тест — не больше двух строк: что подаём, что ожидаем, какую проблему ловим. -## `src/entities/activity/utils.test.ts` +## `src/shared/lib/publicLink.test.ts` + +- `преобразует название в slug`: ввод — строки с пробелами/символами, строка без латиницы, кастомный fallback; вывод — slug или fallback (`activity`/`series`). Проверяет стабильную генерацию безопасной части публичной ссылки для активности и серии. +- `генерирует slug с коротким случайным суффиксом`: ввод — название; вывод — `slug-[a-z0-9]{6}`. Проверяет, что публичные ссылки получают случайный суффикс. +- `генерирует edit token из UUID без дефисов`: ввод отсутствует; вывод — токен `[a-f0-9]{32}`. Проверяет формат секретной ссылки управления. + +## `src/shared/lib/datetime.test.ts` -- `преобразует название в slug`: ввод — строки с пробелами/символами и строка без латиницы; вывод — slug или `activity`. Проверяет стабильную генерацию безопасной части публичной ссылки. -- `генерирует slug с коротким случайным суффиксом`: ввод — название активности; вывод — `slug-[a-z0-9]{6}`. Проверяет, что публичные ссылки получают случайный суффикс. -- `генерирует edit token из 18 символов`: ввод отсутствует; вывод — токен `[a-z0-9]{18}`. Проверяет формат секретной ссылки управления. - `проверяет корректность даты`: ввод — валидный `datetime-local`, `12 мая`, пустая строка; вывод — `true/false`. Проверяет защиту от timestamp-ошибки Supabase до запроса. - `преобразует дату в ISO, если дата корректна`: ввод — валидная и невалидная дата; вывод — ISO или исходная строка. Проверяет корректный payload для поля `starts_at`. - `преобразует дату с бэка в значение для datetime-local`: ввод — дата с timezone и мусорная строка; вывод — `YYYY-MM-DDTHH:mm` или исходная строка. Проверяет отображение даты на странице управления. + +## `src/entities/activity/utils.test.ts` + +- `нормализует Telegram-хэндл в t.me ссылку`: ввод — `@anna`/`anna`/`t.me/anna`/полная ссылка/пусто/`null`; вывод — `https://t.me/anna` или `""`. Проверяет префилл `chat_url` из Telegram профиля под URL-валидацию. - `возвращает следующий статус активности`: ввод — `active`/`cancelled`; вывод — обратный статус. Проверяет логику отмены и возврата активности. +- `считает остаток мест из capacity`: ввод — `capacity`/`going` в трёх режимах (`null`, есть места, мест нет); вывод — `kind` `unlimited`/`available`+`left`/`full`. Проверяет расчёт остатка мест для страницы ивента. + +## `src/entities/series/utils.test.ts` + +- `ежедневное правило даёт подряд идущие дни`: ввод — `unit: day`, горизонт 3; вывод — 3 даты с шагом в сутки от старта. Проверяет базовую генерацию слотов серии. +- `недельное правило по одному дню шагает через неделю`: ввод — `unit: week`, один день; вывод — тот же день недели с шагом 7 дней. Проверяет недельный слот. +- `недельное правило по нескольким дням чередует дни недели`: ввод — `weekdays: [пн, ср]`, горизонт 4; вывод — `[пн, ср, пн, ср]`. Проверяет несколько дней в неделе. +- `интервал в 2 недели удваивает шаг`: ввод — `interval: 2`, `unit: week`; вывод — шаг 14 дней. Проверяет интервал повторения. +- `месячное правило сохраняет день месяца`: ввод — `unit: month`; вывод — тот же день месяца. Проверяет месячный слот. +- `окончание по числу встреч ограничивает количество`: ввод — `end: count 2`, горизонт 5; вывод — 2 даты. Проверяет окончание по числу встреч. +- `окончание по дате обрезает по горизонту`: ввод — `end: until` (старт + 2 дня); вывод — 3 даты включительно. Проверяет окончание по дате. +- `мусорную дату старта отдаёт пустым списком`: ввод — `не дата`; вывод — `[]`. Проверяет защиту от битого старта. +- `описывает правило текстом для бейджа`: ввод — дневное/недельное правило; вывод — `каждый день · 19:00`, `каждый вт · 19:00`, `по пн, ср · 19:00`. Проверяет текст бейджа серии. +- `считает идущих: без отметки идёт, пропуск вычитается`: ввод — 3 участника, отметки `skip`+`going`; вывод — 2. Проверяет расчёт «кто идёт» на встречу. + +## `src/entities/series/schemas.test.ts` + +- `чистит пустые необязательные поля в null`: ввод — форма серии с пустыми описанием/местом/лимитом/обложкой/чатом; вывод — `null` в этих полях. Проверяет контракт чистки перед маппером. +- `требует название, город и корректную дату`: ввод — пустые название/город и мусорная дата; вывод — `success: false`. Проверяет обязательные поля шаблона и защиту от timestamp-ошибки. +- `недельное правило требует хотя бы один день недели`: ввод — `unit: week`, `weekdays: []`; вывод — `success: false`. Проверяет обязательность дней недели для недельного правила. +- `отклоняет неположительный интервал правила`: ввод — `interval: 0`; вывод — `success: false`. Проверяет валидацию интервала повторения. +- `отклоняет неположительное число встреч в окончании`: ввод — `end: count 0`; вывод — `success: false`. Проверяет валидацию окончания по числу встреч. +- `в форме обновления добавляет статус`: ввод — статус `finished` и неизвестный `paused`; вывод — `true`/`false`. Проверяет расширение схемы статусом серии. + +## `src/entities/series/mappers.test.ts` + +- `преобразует форму создания в payload для API`: ввод — провалидированная форма серии с `null` в необязательных полях; вывод — ISO-дата, `slug`, `edit_token`, `active`, `recurrence` как есть. Проверяет payload создания серии. +- `преобразует форму записи в payload участника`: ввод — форма записи с пустым и заполненным Telegram; вывод — `null`/значение и `series_id`. Проверяет payload постоянного участника. +- `преобразует null поля серии в пустые строки для формы управления`: ввод — серия с `null` в описании/месте/обложке/чате; вывод — пустые строки. Проверяет префилл формы управления. +- `сохраняет заполненные поля серии в форме управления`: ввод — серия с чатом, статусом и правилом; вывод — те же значения. Проверяет, что данные серии не теряются в форме. + +## `src/entities/series/local.test.ts` + +- `повторное добавление того же slug не дублирует запись`: ввод — двойной `addMySeries` одной серии; вывод — список длины 1. Проверяет дедуп локального списка «Мои серии». +- `повторная запись в ту же серию не дублирует membership`: ввод — двойной `addMembership` одной серии; вывод — список длины 1. Проверяет дедуп «Хожу постоянно». +- `выход убирает membership и находит по slug`: ввод — `addMembership` + `findMembership` + `removeMembership`; вывод — найденный member, затем `null`. Проверяет поиск и выход из серии. + +## `src/entities/reminder/utils.test.ts` + +- `возвращает старт минус смещение для будущей активности`: ввод — старт через 5ч, смещение 2ч; вывод — момент за 2ч до старта. Проверяет расчёт времени срабатывания напоминания. +- `момент напоминания уже в прошлом → null`: ввод — старт через 1ч, смещение 2ч; вывод — `null`. Проверяет отказ планировать напоминание в прошлом. +- `мусорную дату старта отдаёт null`: ввод — `не дата`; вывод — `null`. Проверяет защиту от битой даты старта. +- `склоняет час по числу`: ввод — 1/2/11/24; вывод — `час`/`часа`/`часов`/`часа`. Проверяет русскую форму слова в тексте уведомления. + +## `src/entities/reminder/local.test.ts` + +- `повторное напоминание того же slug не дублирует, берёт новый id`: ввод — двойной `addReminder` одного slug; вывод — список длины 1 с новым `notificationId`. Проверяет дедуп и перезапись напоминания. +- `удаление по slug убирает запись`: ввод — два напоминания + `removeReminder`; вывод — остаётся другое. Проверяет снятие напоминания при отмене. + +## `src/entities/activity/local.test.ts` + +- `добавляет и убирает slug при повторном тоггле`: ввод — двойной `toggleLike` одного slug; вывод — `[slug]`, затем `[]`. Проверяет, что повторный лайк снимает отметку. +- `тоггл идемпотентен: один slug не дублируется`: ввод — лайки разных slug; вывод — список без дублей в порядке добавления. Проверяет device-local состояние лайков. +- `повторное добавление того же slug не дублирует запись`: ввод — двойной `addMyActivity` одной активности; вывод — список длины 1. Проверяет дедуп локального списка «Мои ивенты». + +## `src/entities/activity/schemas.test.ts` + +- `чистит пустые необязательные поля в null`: ввод — форма создания с пустыми описанием/местом/лимитом/обложкой/чатом; вывод — `null` в этих полях. Проверяет контракт чистки, на который опирается маппер. +- `приводит capacity к положительному числу`: ввод — `capacity: "12"`; вывод — `12`. Проверяет coerce строкового лимита в число. +- `требует название, город и корректную дату`: ввод — пустые название/город и мусорная дата; вывод — `success: false`. Проверяет обязательные поля и защиту от timestamp-ошибки. +- `отклоняет некорректную ссылку на обложку`: ввод — `cover_url` не-URL; вывод — `success: false`. Проверяет валидацию ссылки обложки. +- `отклоняет некорректную ссылку на чат`: ввод — `chat_url` не-URL; вывод — `success: false`. Проверяет валидацию ссылки на чат. +- `в форме обновления добавляет статус`: ввод — статус `cancelled` и неизвестный `deleted`; вывод — `true`/`false`. Проверяет расширение схемы статусом. ## `src/entities/activity/mappers.test.ts` - `преобразует форму создания в payload для API`: ввод — провалидированная форма с `null` в необязательных полях; вывод — `null`, ISO-дата, `slug`, `edit_token`, `active`. Проверяет payload создания (пустые строки в `null` чистит схема). -- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом и обложкой; вывод — те же значения. Проверяет, что маппер не затирает введённые данные. +- `сохраняет заполненные необязательные поля при создании`: ввод — форма с описанием, местом, лимитом, обложкой и чатом; вывод — те же значения. Проверяет, что маппер не затирает введённые данные. - `преобразует форму обновления в payload для API`: ввод — провалидированная форма редактирования с `null` в необязательных полях; вывод — `null`, ISO-дата и статус. Проверяет payload сохранения активности. +- `преобразует null chat_url в пустую строку для формы управления`: ввод — активность с `chat_url: null`; вывод — `""`. Проверяет префилл формы управления пустым полем чата. +- `сохраняет заполненный chat_url в форме управления`: ввод — активность с `chat_url`; вывод — та же ссылка. Проверяет префилл формы управления заданным чатом. ## `src/entities/participant/utils.test.ts` @@ -25,10 +97,42 @@ ## `src/entities/participant/mappers.test.ts` -- `преобразует форму отклика в payload для API`: ввод — форма с пустыми Telegram/comment; вывод — `null` и `activity_id`. Проверяет payload отклика без пустых строк. -- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram/comment; вывод — те же значения. Проверяет, что данные участника не теряются. +- `преобразует форму отклика в payload для API`: ввод — форма с пустым Telegram; вывод — `null`, `activity_id` и `status: going`. Проверяет payload отклика без пустых строк (статус всегда «иду»). +- `сохраняет заполненные необязательные поля`: ввод — форма с Telegram; вывод — те же значения и `status: going`. Проверяет, что данные участника не теряются, а статус всегда «иду». + +## `src/entities/participant/schemas.test.ts` + +- `принимает отклик с обязательным именем и статусом «иду»`: ввод — `name` + `status: going`; вывод — `going`, пустой Telegram. Проверяет минимальную валидную форму отклика. +- `требует непустое имя`: ввод — имя из пробелов; вывод — `success: false`. Проверяет обязательность имени. +- `разрешает только статус «иду»`: ввод — `status: maybe`; вывод — `success: false`. Проверяет литерал статуса после сведения к «иду». + +## `src/entities/participant/local.test.ts` + +- `повторный отклик на тот же slug не дублирует запись`: ввод — двойной `addMyResponse` одного slug; вывод — список длины 1. Проверяет дедуп локального списка «Посещено». + +## `src/entities/profile/schemas.test.ts` + +- `пустые текстовые поля и соц.ссылки превращает в null`: ввод — форма с пустыми городом, фото и всеми соцсетями; вывод — `null` в этих полях, `categories` по умолчанию `[]`. Проверяет чистку пустых строк перед сохранением профиля. +- `сохраняет заполненные поля и интересы`: ввод — заполненные город/фото/интересы/Telegram; вывод — те же значения, незаполненные соцсети `null`. Проверяет, что введённые данные не теряются. +- `принимает локальный URI фото и отклоняет пустое имя`: ввод — локальный `file://` URI фото и имя из пробелов; вывод — `true` для фото, `false` для имени. Проверяет, что device-URI фото не отклоняется, а имя обязательно. + +## `src/entities/profile/mappers.test.ts` + +- `null в полях профиля превращает в пустую строку для формы`: ввод — профиль с `null` в городе/фото/соцсетях; вывод — пустые строки. Проверяет префилл формы редактирования из хранилища. +- `сохраняет заполненные поля и не тащит дату регистрации`: ввод — заполненный профиль с `registeredAt`; вывод — те же значения без `registeredAt`. Проверяет, что дата регистрации не попадает в форму. + +## `src/entities/profile/utils.test.ts` + +- `возвращает только заполненные соц.ссылки в порядке словаря`: ввод — соцсети вперемешку с `null`; вывод — только заполненные в порядке `socialOptions`. Проверяет рендер и переход наружу. +- `форматирует дату регистрации, мусор отдаёт как есть`: ввод — валидная дата и мусорная строка; вывод — `ДД.ММ.ГГГГ` или исходная строка. Проверяет отображение даты регистрации в профиле. ## `src/shared/errors/getRussianErrorMessage.test.ts` - `переводит известные технические ошибки`: ввод — timestamp/network ошибки; вывод — русские сообщения. Проверяет, что технические backend/browser ошибки не попадают в UI. - `возвращает fallback для неизвестной ошибки`: ввод — неизвестная Error и не-Error значение; вывод — fallback. Проверяет безопасное русское сообщение для любых неожиданных ошибок. + +## `src/shared/lib/shareActivity.test.ts` + +- `успешный шеринг возвращает kind «shared»`: ввод — `Share.share` резолвится; вывод — `{ kind: "shared" }`. Проверяет открытие системного листа на нативе. +- `отмену листа (AbortError) не считает ошибкой`: ввод — `Share.share` бросает `AbortError`; вывод — `{ kind: "shared" }`. Проверяет, что отмена листа не показывается как ошибка. +- `прочую ошибку отдаёт с русским сообщением`: ввод — `Share.share` бросает обычную ошибку; вывод — `{ kind: "error" }` с русским текстом. Проверяет, что сырые сообщения не утекают в UI. diff --git a/app.json b/app.json index 6c05b2c..2da253b 100644 --- a/app.json +++ b/app.json @@ -22,6 +22,17 @@ "web": { "favicon": "./assets/favicon.png" }, - "plugins": ["expo-router", "expo-status-bar"] + "plugins": [ + "expo-router", + "expo-status-bar", + [ + "expo-image-picker", + { + "photosPermission": "Venty использует фото из галереи для аватара профиля.", + "microphonePermission": false + } + ], + "expo-notifications" + ] } } diff --git a/app/_layout.tsx b/app/_layout.tsx index c2a5ab7..3b8299b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,10 +1,24 @@ import "../global.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as Notifications from "expo-notifications"; import { Stack } from "expo-router"; import { useState } from "react"; -import { Text, View } from "react-native"; +import { Platform, Text, View } from "react-native"; import { TextLink } from "../src/shared/ui/TextLink"; +// Показ запланированного напоминания, пока приложение на переднем плане. +// На web expo-notifications нет — обработчик ставим только на нативе. +if (Platform.OS !== "web") { + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: false, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), + }); +} + export default function RootLayout() { const [client] = useState(() => new QueryClient()); return ( @@ -14,6 +28,7 @@ export default function RootLayout() { Главная Создать + Профиль diff --git a/app/a/[slug].tsx b/app/a/[slug].tsx index 176898c..dc18b69 100644 --- a/app/a/[slug].tsx +++ b/app/a/[slug].tsx @@ -1,12 +1,17 @@ import { Image, Text } from "react-native"; +import { getCapacityState } from "../../src/entities/activity/utils"; import { getParticipantStatusText } from "../../src/entities/participant/utils"; +import { useLikeActivity } from "../../src/features/like-activity/useLikeActivity"; import { ResponseForm } from "../../src/features/respond-to-activity/ResponseForm"; import { useRespondToActivityForm } from "../../src/features/respond-to-activity/useRespondToActivityForm"; import { ActivityNotFound } from "../../src/shared/ui/ActivityNotFound"; import { BackLink } from "../../src/shared/ui/BackLink"; +import { Button } from "../../src/shared/ui/Button"; +import { ChatLinkButton } from "../../src/shared/ui/ChatLinkButton"; import { LoadingPage } from "../../src/shared/ui/LoadingPage"; import { Page } from "../../src/shared/ui/Page"; import { ParticipantList } from "../../src/shared/ui/ParticipantList"; +import { ShareButton } from "../../src/shared/ui/ShareButton"; export default function ActivityPage() { const { @@ -20,6 +25,7 @@ export default function ActivityPage() { submit, isSaving, hasResponded, + reminderScheduled, } = useRespondToActivityForm(); if (isLoading) return ; @@ -43,14 +49,11 @@ export default function ActivityPage() { {activity.status === "cancelled" ? ( ОТМЕНЕНО ) : null} - - Идут: {stats.going} Может быть: {stats.maybe} Не могут: {stats.cant} - - {activity.capacity ? ( - - {stats.going} / {activity.capacity} идут - - ) : null} + Идут: {stats.going} + + + + {activity.chat_url ? : null} {activity.status !== "cancelled" && !hasResponded ? ( ) : null} @@ -59,6 +62,7 @@ export default function ActivityPage() { Ты уже откликнулся с этого устройства. ) : null} + {reminderScheduled ? Напомним за 2 часа до начала. : null} ); } + +function LikeButton({ slug }: { slug: string }) { + const { isLiked, toggle, isToggling } = useLikeActivity(slug); + + return ( +